mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Merge remote-tracking branch 'origin/main' into worktree-select
This commit is contained in:
688
apps/ui/tests/context-view.spec.ts
Normal file
688
apps/ui/tests/context-view.spec.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
resetContextDirectory,
|
||||
createContextFileOnDisk,
|
||||
contextFileExistsOnDisk,
|
||||
setupProjectWithFixture,
|
||||
getFixturePath,
|
||||
navigateToContext,
|
||||
waitForFileContentToLoad,
|
||||
switchToEditMode,
|
||||
waitForContextFile,
|
||||
selectContextFile,
|
||||
simulateFileDrop,
|
||||
setContextEditorContent,
|
||||
getContextEditorContent,
|
||||
clickElement,
|
||||
fillInput,
|
||||
getByTestId,
|
||||
waitForNetworkIdle,
|
||||
} from "./utils";
|
||||
|
||||
const WORKSPACE_ROOT = path.resolve(process.cwd(), "../..");
|
||||
const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, "apps/ui/public/logo.png");
|
||||
|
||||
// Configure all tests to run serially to prevent interference with shared context directory
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 1: Context View - File Management
|
||||
// ============================================================================
|
||||
test.describe("Context View - File Management", () => {
|
||||
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should create a new MD context file", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
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");
|
||||
|
||||
// Enter filename
|
||||
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);
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for file list to refresh (file should appear)
|
||||
await waitForContextFile(page, "test-context.md", 10000);
|
||||
|
||||
// Verify file appears in list
|
||||
const fileButton = await getByTestId(page, "context-file-test-context.md");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Click on the file and wait for it to be selected
|
||||
await selectContextFile(page, "test-context.md");
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode if in preview mode (markdown files default to preview)
|
||||
await switchToEditMode(page);
|
||||
|
||||
// Wait for editor to be visible
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify content in editor
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe(testContent);
|
||||
});
|
||||
|
||||
test("should edit an existing MD context file", async ({ page }) => {
|
||||
// Create a test file on disk first
|
||||
const originalContent = "# Original Content\n\nThis will be edited.";
|
||||
createContextFileOnDisk("edit-test.md", originalContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the existing file and wait for it to be selected
|
||||
await selectContextFile(page, "edit-test.md");
|
||||
|
||||
// Wait for file content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode by default)
|
||||
await switchToEditMode(page);
|
||||
|
||||
// Wait for editor
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Modify content
|
||||
const newContent = "# Modified Content\n\nThis has been edited.";
|
||||
await setContextEditorContent(page, newContent);
|
||||
|
||||
// Click save
|
||||
await clickElement(page, "save-context-file");
|
||||
|
||||
// Wait for save to complete
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="save-context-file"]')
|
||||
?.textContent?.includes("Saved"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to context view
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for file to appear after reload and select it
|
||||
await selectContextFile(page, "edit-test.md");
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify content persisted
|
||||
const persistedContent = await getContextEditorContent(page);
|
||||
expect(persistedContent).toBe(newContent);
|
||||
});
|
||||
|
||||
test("should remove an MD context file", async ({ page }) => {
|
||||
// Create a test file on disk first
|
||||
createContextFileOnDisk("delete-test.md", "# Delete Me");
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the file to select it
|
||||
const fileButton = await getByTestId(page, "context-file-delete-test.md");
|
||||
await fileButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await fileButton.click();
|
||||
|
||||
// Click delete button
|
||||
await clickElement(page, "delete-context-file");
|
||||
|
||||
// Wait for delete dialog
|
||||
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
await clickElement(page, "confirm-delete-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="delete-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is removed from list
|
||||
const deletedFile = await getByTestId(page, "context-file-delete-test.md");
|
||||
await expect(deletedFile).not.toBeVisible();
|
||||
|
||||
// Verify file is removed from disk
|
||||
expect(contextFileExistsOnDisk("delete-test.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("should upload an image context file", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
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");
|
||||
|
||||
// Enter filename
|
||||
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
|
||||
);
|
||||
|
||||
// Wait for image preview to appear (indicates upload success)
|
||||
const addDialog = await getByTestId(page, "add-context-dialog");
|
||||
await addDialog.locator("img").waitFor({ state: "visible" });
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file appears in list
|
||||
const fileButton = await getByTestId(page, "context-file-test-image.png");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Click on the image to view it
|
||||
await fileButton.click();
|
||||
|
||||
// Verify image preview is displayed
|
||||
await page.waitForSelector('[data-testid="image-preview"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
const imagePreview = await getByTestId(page, "image-preview");
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("should remove an image context file", async ({ page }) => {
|
||||
// Create a test image file on disk as base64 data URL (matching app's storage format)
|
||||
const imageContent = fs.readFileSync(TEST_IMAGE_SRC);
|
||||
const base64DataUrl = `data:image/png;base64,${imageContent.toString("base64")}`;
|
||||
const contextPath = path.join(getFixturePath(), ".automaker/context");
|
||||
fs.writeFileSync(path.join(contextPath, "delete-image.png"), base64DataUrl);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for the image file and select it
|
||||
await selectContextFile(page, "delete-image.png");
|
||||
|
||||
// Wait for file content (image preview) to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Click delete button
|
||||
await clickElement(page, "delete-context-file");
|
||||
|
||||
// Wait for delete dialog
|
||||
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
await clickElement(page, "confirm-delete-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="delete-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is removed from list
|
||||
const deletedImageFile = await getByTestId(page, "context-file-delete-image.png");
|
||||
await expect(deletedImageFile).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should toggle markdown preview mode", async ({ page }) => {
|
||||
// Create a markdown file with content
|
||||
const mdContent =
|
||||
"# Heading\n\n**Bold text** and *italic text*\n\n- List item 1\n- List item 2";
|
||||
createContextFileOnDisk("preview-test.md", mdContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the markdown file
|
||||
const fileButton = await getByTestId(page, "context-file-preview-test.md");
|
||||
await fileButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await fileButton.click();
|
||||
|
||||
// Wait for content to load (markdown files open in preview mode by default)
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Check if preview button is visible (indicates it's a markdown file)
|
||||
const previewToggle = await getByTestId(page, "toggle-preview-mode");
|
||||
await expect(previewToggle).toBeVisible();
|
||||
|
||||
// Markdown files always open in preview mode by default (see context-view.tsx:163)
|
||||
// Verify we're in preview mode
|
||||
const markdownPreview = await getByTestId(page, "markdown-preview");
|
||||
await expect(markdownPreview).toBeVisible();
|
||||
|
||||
// Click to switch to edit mode
|
||||
await previewToggle.click();
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify editor is shown
|
||||
const editor = await getByTestId(page, "context-editor");
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(markdownPreview).not.toBeVisible();
|
||||
|
||||
// Click to switch back to preview mode
|
||||
await previewToggle.click();
|
||||
await page.waitForSelector('[data-testid="markdown-preview"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify preview is shown
|
||||
await expect(markdownPreview).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 2: Context View - Drag and Drop
|
||||
// ============================================================================
|
||||
test.describe("Context View - Drag and Drop", () => {
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should handle drag and drop of MD file onto textarea in add dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Open add file dialog
|
||||
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");
|
||||
|
||||
// Simulate drag and drop of a .md file onto the textarea
|
||||
const droppedContent = "# Dropped Content\n\nThis was dragged and dropped.";
|
||||
await simulateFileDrop(
|
||||
page,
|
||||
'[data-testid="new-file-content"]',
|
||||
"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" });
|
||||
await expect(textarea).toHaveValue(droppedContent);
|
||||
|
||||
// Verify content is populated in textarea
|
||||
const textareaContent = await textarea.inputValue();
|
||||
expect(textareaContent).toBe(droppedContent);
|
||||
|
||||
// Verify filename is auto-filled
|
||||
const filenameValue = await page
|
||||
.locator('[data-testid="new-file-name"]')
|
||||
.inputValue();
|
||||
expect(filenameValue).toBe("dropped-file.md");
|
||||
|
||||
// Confirm and create the file
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file was created
|
||||
const droppedFile = await getByTestId(page, "context-file-dropped-file.md");
|
||||
await expect(droppedFile).toBeVisible();
|
||||
});
|
||||
|
||||
test("should handle drag and drop of file onto main view", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for the context view to be fully loaded
|
||||
await page.waitForSelector('[data-testid="context-file-list"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Simulate drag and drop onto the drop zone
|
||||
const droppedContent = "This is a text file dropped onto the main view.";
|
||||
await simulateFileDrop(
|
||||
page,
|
||||
'[data-testid="context-drop-zone"]',
|
||||
"main-drop.txt",
|
||||
droppedContent
|
||||
);
|
||||
|
||||
// Wait for file to appear in the list (drag-drop triggers file creation)
|
||||
await waitForContextFile(page, "main-drop.txt", 15000);
|
||||
|
||||
// Verify file appears in the file list
|
||||
const fileButton = await getByTestId(page, "context-file-main-drop.txt");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Select file and verify content
|
||||
await fileButton.click();
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe(droppedContent);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 3: Context View - Edge Cases
|
||||
// ============================================================================
|
||||
test.describe("Context View - Edge Cases", () => {
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should handle duplicate filename (overwrite behavior)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an existing file
|
||||
createContextFileOnDisk("test.md", "# Original Content");
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Verify the original file exists
|
||||
const originalFile = await getByTestId(page, "context-file-test.md");
|
||||
await expect(originalFile).toBeVisible();
|
||||
|
||||
// Try to create another file with the same name
|
||||
await clickElement(page, "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, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// File should still exist (was overwritten)
|
||||
await expect(originalFile).toBeVisible();
|
||||
|
||||
// Select the file and verify the new content
|
||||
await originalFile.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe("# New Content - Overwritten");
|
||||
});
|
||||
|
||||
test("should handle special characters in filename", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Test file with parentheses
|
||||
await clickElement(page, "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, "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");
|
||||
await expect(fileWithParens).toBeVisible();
|
||||
|
||||
// Test file with hyphens and underscores
|
||||
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, "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");
|
||||
await expect(fileWithHyphens).toBeVisible();
|
||||
|
||||
// Verify both files are accessible
|
||||
await fileWithHyphens.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const content = await getContextEditorContent(page);
|
||||
expect(content).toBe("Content with hyphens and underscores");
|
||||
});
|
||||
|
||||
test("should handle empty content", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Create file with empty content
|
||||
await clickElement(page, "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");
|
||||
// Don't fill any content - leave it empty
|
||||
|
||||
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");
|
||||
await expect(emptyFile).toBeVisible();
|
||||
|
||||
// Select file and verify editor shows empty content
|
||||
await emptyFile.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe("");
|
||||
|
||||
// Verify save works with empty content
|
||||
// The save button should be disabled when there are no changes
|
||||
// Let's add some content first, then clear it and save
|
||||
await setContextEditorContent(page, "temporary");
|
||||
await setContextEditorContent(page, "");
|
||||
|
||||
// Save should work
|
||||
await clickElement(page, "save-context-file");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="save-context-file"]')
|
||||
?.textContent?.includes("Saved"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("should verify persistence across page refresh", async ({ page }) => {
|
||||
// Create a file directly on disk to ensure it persists across refreshes
|
||||
const testContent = "# Persistence Test\n\nThis content should persist.";
|
||||
createContextFileOnDisk("persist-test.md", testContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Verify file exists before refresh
|
||||
await waitForContextFile(page, "persist-test.md", 10000);
|
||||
|
||||
// Refresh the page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to context view
|
||||
await navigateToContext(page);
|
||||
|
||||
// Select the file after refresh (uses robust clicking mechanism)
|
||||
await selectContextFile(page, "persist-test.md");
|
||||
|
||||
// Wait for file content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const persistedContent = await getContextEditorContent(page);
|
||||
expect(persistedContent).toBe(testContent);
|
||||
});
|
||||
});
|
||||
536
apps/ui/tests/feature-lifecycle.spec.ts
Normal file
536
apps/ui/tests/feature-lifecycle.spec.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Feature Lifecycle End-to-End Tests
|
||||
*
|
||||
* Tests the complete feature lifecycle flow:
|
||||
* 1. Create a feature in backlog
|
||||
* 2. Drag to in_progress and wait for agent to finish
|
||||
* 3. Verify it moves to waiting_approval (manual review)
|
||||
* 4. Click commit and verify git status shows committed changes
|
||||
* 5. Drag to verified column
|
||||
* 6. Archive (complete) the feature
|
||||
* 7. Open archive modal and restore the feature
|
||||
* 8. Delete the feature
|
||||
*
|
||||
* NOTE: This test uses AUTOMAKER_MOCK_AGENT=true to mock the agent
|
||||
* so it doesn't make real API calls during CI/CD runs.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
import {
|
||||
waitForNetworkIdle,
|
||||
createTestGitRepo,
|
||||
cleanupTempDir,
|
||||
createTempDirPath,
|
||||
setupProjectWithPathNoWorktrees,
|
||||
waitForBoardView,
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
dragAndDropWithDndKit,
|
||||
} from "./utils";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
|
||||
|
||||
interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Configure all tests to run serially
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Feature Lifecycle Tests", () => {
|
||||
let testRepo: TestRepo;
|
||||
let featureId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Create a fresh test repo for each test
|
||||
testRepo = await createTestGitRepo(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Cleanup test repo after each test
|
||||
if (testRepo) {
|
||||
await testRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
// this one fails in github actions for some reason
|
||||
test.skip("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Increase timeout for this comprehensive test
|
||||
test.setTimeout(120000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait a bit for the UI to fully load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details - requesting a file with "yellow" content
|
||||
const featureDescription =
|
||||
"Create a file named yellow.txt that contains the text yellow";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Debug: Check the filesystem to see if feature was created
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to force features to load from filesystem
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear on the board
|
||||
const featureCard = page.getByText(featureDescription).first();
|
||||
await expect(featureCard).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Get the feature ID from the filesystem
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
featureId = featureDirs[0];
|
||||
|
||||
// Now get the actual card element by testid
|
||||
const featureCardByTestId = page.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress and wait for agent to finish
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
// Perform the drag and drop using dnd-kit compatible method
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// First verify that the drag succeeded by checking for in_progress status
|
||||
// This helps diagnose if the drag-drop is working or not
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
|
||||
// Wait for the feature to move to waiting_approval (manual review)
|
||||
// The status changes are: in_progress -> waiting_approval after agent completes
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Refresh page to ensure UI reflects the status change
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 3: Verify feature is in waiting_approval (manual review) column
|
||||
// ==========================================================================
|
||||
const waitingApprovalColumn = page.locator(
|
||||
'[data-testid="kanban-column-waiting_approval"]'
|
||||
);
|
||||
const cardInWaitingApproval = waitingApprovalColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the mock agent created the yellow.txt file
|
||||
const yellowFilePath = path.join(testRepo.path, "yellow.txt");
|
||||
expect(fs.existsSync(yellowFilePath)).toBe(true);
|
||||
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
|
||||
expect(yellowContent).toBe("yellow");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Click commit and verify git status shows committed changes
|
||||
// ==========================================================================
|
||||
// The commit button should be visible on the card in waiting_approval
|
||||
const commitButton = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
await expect(commitButton).toBeVisible({ timeout: 5000 });
|
||||
await commitButton.click();
|
||||
|
||||
// Wait for the commit to process
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify git status shows clean (changes committed)
|
||||
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
// After commit, the yellow.txt file should be committed, so git status should be clean
|
||||
// (only .automaker directory might have changes)
|
||||
expect(gitStatus.includes("yellow.txt")).toBe(false);
|
||||
|
||||
// Verify the commit exists in git log
|
||||
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
expect(gitLog.toLowerCase()).toContain("yellow");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Verify feature moved to verified column after commit
|
||||
// ==========================================================================
|
||||
// Feature should automatically move to verified after commit
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
const verifiedColumn = page.locator(
|
||||
'[data-testid="kanban-column-verified"]'
|
||||
);
|
||||
const cardInVerified = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 6: Archive (complete) the feature
|
||||
// ==========================================================================
|
||||
// Click the Complete button on the verified card
|
||||
const completeButton = page.locator(
|
||||
`[data-testid="complete-${featureId}"]`
|
||||
);
|
||||
await expect(completeButton).toBeVisible({ timeout: 5000 });
|
||||
await completeButton.click();
|
||||
|
||||
// Wait for the archive action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the feature is no longer visible on the board (it's archived)
|
||||
await expect(cardInVerified).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify feature status is completed in filesystem
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(featureData.status).toBe("completed");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 7: Open archive modal and restore the feature
|
||||
// ==========================================================================
|
||||
// Click the completed features button to open the archive modal
|
||||
const completedFeaturesButton = page.locator(
|
||||
'[data-testid="completed-features-button"]'
|
||||
);
|
||||
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
|
||||
await completedFeaturesButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
const completedModal = page.locator(
|
||||
'[data-testid="completed-features-modal"]'
|
||||
);
|
||||
await expect(completedModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the archived feature is shown in the modal
|
||||
const archivedCard = completedModal.locator(
|
||||
`[data-testid="completed-card-${featureId}"]`
|
||||
);
|
||||
await expect(archivedCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the restore button
|
||||
const restoreButton = page.locator(
|
||||
`[data-testid="unarchive-${featureId}"]`
|
||||
);
|
||||
await expect(restoreButton).toBeVisible({ timeout: 5000 });
|
||||
await restoreButton.click();
|
||||
|
||||
// Wait for the restore action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Close the modal - use first() to select the footer Close button, not the X button
|
||||
const closeButton = completedModal
|
||||
.locator('button:has-text("Close")')
|
||||
.first();
|
||||
await closeButton.click();
|
||||
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the feature is back in the verified column
|
||||
const restoredCard = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(restoredCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify feature status is verified in filesystem
|
||||
const restoredFeatureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(restoredFeatureData.status).toBe("verified");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 8: Delete the feature and verify it's removed
|
||||
// ==========================================================================
|
||||
// Click the delete button on the verified card
|
||||
const deleteButton = page.locator(
|
||||
`[data-testid="delete-verified-${featureId}"]`
|
||||
);
|
||||
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
||||
await deleteButton.click();
|
||||
|
||||
// Wait for the confirmation dialog
|
||||
const confirmDialog = page.locator(
|
||||
'[data-testid="delete-confirmation-dialog"]'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the confirm delete button
|
||||
const confirmDeleteButton = page.locator(
|
||||
'[data-testid="confirm-delete-button"]'
|
||||
);
|
||||
await confirmDeleteButton.click();
|
||||
|
||||
// Wait for the delete action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the feature is no longer visible on the board
|
||||
await expect(restoredCard).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the feature directory is deleted from filesystem
|
||||
const featureDirExists = fs.existsSync(path.join(featuresDir, featureId));
|
||||
expect(featureDirExists).toBe(false);
|
||||
});
|
||||
|
||||
// this one fails in github actions for some reason
|
||||
test.skip("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test verifies that stopping a feature and restarting it works correctly
|
||||
// Bug: Previously, stopping a feature and immediately restarting could cause
|
||||
// "Feature not found" error due to race conditions
|
||||
test.setTimeout(120000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
const featureDescription = "Create a file named test-restart.txt";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Get the feature ID
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
const testFeatureId = featureDirs[0];
|
||||
|
||||
// Reload to ensure features are loaded
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear
|
||||
const featureCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress (first start)
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// Verify feature file still exists and is readable
|
||||
const featureFilePath = path.join(
|
||||
featuresDir,
|
||||
testFeatureId,
|
||||
"feature.json"
|
||||
);
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 3: Wait for the mock agent to complete (it's fast in mock mode)
|
||||
// ==========================================================================
|
||||
// The mock agent completes quickly, so we wait for it to finish
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Verify feature file still exists after completion
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
const featureDataAfterComplete = JSON.parse(
|
||||
fs.readFileSync(featureFilePath, "utf-8")
|
||||
);
|
||||
console.log(
|
||||
"Feature status after first run:",
|
||||
featureDataAfterComplete.status
|
||||
);
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Move feature back to backlog to simulate stop scenario
|
||||
// ==========================================================================
|
||||
// Feature is in waiting_approval, drag it back to backlog
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const currentCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
const currentDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
|
||||
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
||||
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify feature is in backlog
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("backlog");
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Restart the feature (drag to in_progress again)
|
||||
// ==========================================================================
|
||||
const restartCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const restartDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumnRestart = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
// Listen for console errors to catch "Feature not found"
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Drag to in_progress to restart
|
||||
await dragAndDropWithDndKit(
|
||||
page,
|
||||
restartDragHandle,
|
||||
inProgressColumnRestart
|
||||
);
|
||||
|
||||
// Verify the feature file still exists
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the restart drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(data.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// Verify no "Feature not found" errors in console
|
||||
const featureNotFoundErrors = consoleErrors.filter(
|
||||
(err) => err.includes("not found") || err.includes("Feature")
|
||||
);
|
||||
expect(featureNotFoundErrors).toEqual([]);
|
||||
|
||||
// Wait for the mock agent to complete and move to waiting_approval
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
console.log("Feature successfully restarted after stop!");
|
||||
});
|
||||
});
|
||||
207
apps/ui/tests/kanban-responsive-scaling.spec.ts
Normal file
207
apps/ui/tests/kanban-responsive-scaling.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Kanban Board Responsive Scaling Tests
|
||||
*
|
||||
* Tests that the Kanban board columns scale intelligently to fill
|
||||
* the available window space without dead space or content being cut off.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
|
||||
import {
|
||||
waitForNetworkIdle,
|
||||
createTestGitRepo,
|
||||
cleanupTempDir,
|
||||
createTempDirPath,
|
||||
setupProjectWithPathNoWorktrees,
|
||||
waitForBoardView,
|
||||
} from "./utils";
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath("kanban-responsive-tests");
|
||||
|
||||
interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
test.describe("Kanban Responsive Scaling Tests", () => {
|
||||
let testRepo: TestRepo;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Create a fresh test repo for each test
|
||||
testRepo = await createTestGitRepo(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Cleanup test repo after each test
|
||||
if (testRepo) {
|
||||
await testRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test("kanban columns should scale to fill available width at different viewport sizes", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Setup project and navigate to board view
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the board to fully render
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get all four kanban columns
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||
|
||||
// Verify all columns are visible
|
||||
await expect(backlogColumn).toBeVisible();
|
||||
await expect(inProgressColumn).toBeVisible();
|
||||
await expect(waitingApprovalColumn).toBeVisible();
|
||||
await expect(verifiedColumn).toBeVisible();
|
||||
|
||||
// Test at different viewport widths
|
||||
const viewportWidths = [1024, 1280, 1440, 1920];
|
||||
|
||||
for (const width of viewportWidths) {
|
||||
// Set viewport size
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.waitForTimeout(300); // Wait for resize to take effect
|
||||
|
||||
// Get column widths
|
||||
const backlogBox = await backlogColumn.boundingBox();
|
||||
const inProgressBox = await inProgressColumn.boundingBox();
|
||||
const waitingApprovalBox = await waitingApprovalColumn.boundingBox();
|
||||
const verifiedBox = await verifiedColumn.boundingBox();
|
||||
|
||||
expect(backlogBox).not.toBeNull();
|
||||
expect(inProgressBox).not.toBeNull();
|
||||
expect(waitingApprovalBox).not.toBeNull();
|
||||
expect(verifiedBox).not.toBeNull();
|
||||
|
||||
if (backlogBox && inProgressBox && waitingApprovalBox && verifiedBox) {
|
||||
// All columns should have the same width
|
||||
const columnWidths = [
|
||||
backlogBox.width,
|
||||
inProgressBox.width,
|
||||
waitingApprovalBox.width,
|
||||
verifiedBox.width,
|
||||
];
|
||||
|
||||
// All columns should be equal width (within 2px tolerance for rounding)
|
||||
const baseWidth = columnWidths[0];
|
||||
for (const columnWidth of columnWidths) {
|
||||
expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2);
|
||||
}
|
||||
|
||||
// Column width should be within expected bounds (280px min, 360px max)
|
||||
expect(baseWidth).toBeGreaterThanOrEqual(280);
|
||||
expect(baseWidth).toBeLessThanOrEqual(360);
|
||||
|
||||
// Columns should not overlap (check x positions)
|
||||
expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5);
|
||||
expect(waitingApprovalBox.x).toBeGreaterThan(inProgressBox.x + inProgressBox.width - 5);
|
||||
expect(verifiedBox.x).toBeGreaterThan(waitingApprovalBox.x + waitingApprovalBox.width - 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("kanban columns should be centered in the viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Setup project and navigate to board view
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the board to fully render
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Set a specific viewport size
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Get the first and last columns
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||
|
||||
const backlogBox = await backlogColumn.boundingBox();
|
||||
const verifiedBox = await verifiedColumn.boundingBox();
|
||||
|
||||
expect(backlogBox).not.toBeNull();
|
||||
expect(verifiedBox).not.toBeNull();
|
||||
|
||||
if (backlogBox && verifiedBox) {
|
||||
// Get the actual container width (accounting for sidebar)
|
||||
// The board-view container is inside a flex container that accounts for sidebar
|
||||
const containerWidth = await page.evaluate(() => {
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
if (!boardView) return window.innerWidth;
|
||||
const parent = boardView.parentElement;
|
||||
return parent ? parent.clientWidth : window.innerWidth;
|
||||
});
|
||||
|
||||
// Calculate the left and right margins relative to the container
|
||||
// The bounding box x is relative to the viewport, so we need to find where
|
||||
// the container starts relative to the viewport
|
||||
const containerLeft = await page.evaluate(() => {
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
if (!boardView) return 0;
|
||||
const parent = boardView.parentElement;
|
||||
if (!parent) return 0;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
return rect.left;
|
||||
});
|
||||
|
||||
// Calculate margins relative to the container
|
||||
const leftMargin = backlogBox.x - containerLeft;
|
||||
const rightMargin = containerWidth - (verifiedBox.x + verifiedBox.width - containerLeft);
|
||||
|
||||
// The margins should be roughly equal (columns are centered)
|
||||
// Allow for some tolerance due to padding and gaps
|
||||
const marginDifference = Math.abs(leftMargin - rightMargin);
|
||||
expect(marginDifference).toBeLessThan(50); // Should be reasonably centered
|
||||
}
|
||||
});
|
||||
|
||||
test("kanban columns should have no horizontal scrollbar at standard viewport width", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Setup project and navigate to board view
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Set a standard viewport size (1400px which is the default window width)
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check if horizontal scrollbar is present by comparing scrollWidth and clientWidth
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||
if (!boardContainer) return false;
|
||||
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||
});
|
||||
|
||||
// There should be no horizontal scroll at standard width since columns scale down
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
});
|
||||
});
|
||||
1043
apps/ui/tests/profiles-view.spec.ts
Normal file
1043
apps/ui/tests/profiles-view.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
324
apps/ui/tests/spec-editor-persistence.spec.ts
Normal file
324
apps/ui/tests/spec-editor-persistence.spec.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
resetFixtureSpec,
|
||||
setupProjectWithFixture,
|
||||
getFixturePath,
|
||||
navigateToSpecEditor,
|
||||
getEditorContent,
|
||||
setEditorContent,
|
||||
clickSaveButton,
|
||||
getByTestId,
|
||||
clickElement,
|
||||
fillInput,
|
||||
waitForNetworkIdle,
|
||||
waitForElement,
|
||||
} from "./utils";
|
||||
|
||||
test.describe("Spec Editor Persistence", () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up - reset the spec file after each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test("should open project, edit spec, save, and persist changes after refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Use the resolved fixture path
|
||||
const fixturePath = getFixturePath();
|
||||
|
||||
// Step 1: Set up the project in localStorage pointing to our fixture
|
||||
await setupProjectWithFixture(page, fixturePath);
|
||||
|
||||
// Step 2: Navigate to the app
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Step 3: Verify we're on the dashboard with the project loaded
|
||||
// The sidebar should show the project selector
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Step 4: Click on the Spec Editor in the sidebar
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Step 5: Wait for the spec editor to load
|
||||
const specEditor = await getByTestId(page, "spec-editor");
|
||||
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
|
||||
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor is fully initialized
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 7: Modify the editor content to "hello world"
|
||||
await setEditorContent(page, "hello world");
|
||||
|
||||
// Step 8: Click the save button
|
||||
await clickSaveButton(page);
|
||||
|
||||
// Step 9: Refresh the page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Step 10: Navigate back to the spec editor
|
||||
// After reload, we need to wait for the app to initialize
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
|
||||
// Navigate to spec editor again
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Wait for CodeMirror to be ready
|
||||
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor content is loaded
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 11: Verify the content was persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("should handle opening project via Open Project button and file browser", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test covers the flow of:
|
||||
// 1. Clicking Open Project button
|
||||
// 2. Using the file browser to navigate to the fixture directory
|
||||
// 3. Opening the project
|
||||
// 4. Editing the spec
|
||||
|
||||
// Set up without a current project to test the open project flow
|
||||
await page.addInitScript(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for the sidebar to be visible
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
|
||||
// Check if the button is visible (it might not be in collapsed sidebar)
|
||||
const isButtonVisible = await openProjectButton
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (isButtonVisible) {
|
||||
await clickElement(page, "open-project-button");
|
||||
|
||||
// The file browser dialog should open
|
||||
// Note: In web mode, this might use the FileBrowserDialog component
|
||||
// which makes requests to the backend server at /api/fs/browse
|
||||
|
||||
// Wait a bit to see if a dialog appears
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if a dialog is visible
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
const dialogVisible = await dialog.isVisible().catch(() => false);
|
||||
|
||||
if (dialogVisible) {
|
||||
// If file browser dialog is open, we need to navigate to the fixture path
|
||||
// This depends on the current directory structure
|
||||
|
||||
// For now, let's verify the dialog appeared and close it
|
||||
// A full test would navigate through directories
|
||||
console.log("File browser dialog opened successfully");
|
||||
|
||||
// Press Escape to close the dialog
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
// For a complete e2e test with file browsing, we'd need to:
|
||||
// 1. Navigate through the directory tree
|
||||
// 2. Select the projectA directory
|
||||
// 3. Click "Select Current Folder"
|
||||
|
||||
// Since this involves actual file system navigation,
|
||||
// and depends on the backend server being properly configured,
|
||||
// we'll verify the basic UI elements are present
|
||||
|
||||
expect(sidebar).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up - reset the spec file after each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
// Skip in CI - file browser navigation is flaky in headless environments
|
||||
test.skip("should open project via file browser, edit spec, and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to app first
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Set up localStorage state (without a current project, but mark setup complete)
|
||||
// Using evaluate instead of addInitScript so it only runs once
|
||||
// Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
|
||||
await page.evaluate(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Reload to apply the localStorage state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for sidebar
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "open-project-button");
|
||||
|
||||
// Wait for the file browser dialog to open
|
||||
const dialogTitle = page.locator('text="Select Project Directory"');
|
||||
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Wait for the dialog to fully load (loading to complete)
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Use the path input to directly navigate to the fixture directory
|
||||
const pathInput = await getByTestId(page, "path-input");
|
||||
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Clear the input and type the full path to the fixture
|
||||
await fillInput(page, "path-input", getFixturePath());
|
||||
|
||||
// Click the Go button to navigate to the path
|
||||
await clickElement(page, "go-to-path-button");
|
||||
|
||||
// Wait for loading to complete
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Verify we're in the right directory by checking the path display
|
||||
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
|
||||
await expect(pathDisplay).toContainText("projectA");
|
||||
|
||||
// Click "Select Current Folder" button
|
||||
const selectFolderButton = page.locator(
|
||||
'button:has-text("Select Current Folder")'
|
||||
);
|
||||
await selectFolderButton.click();
|
||||
|
||||
// Wait for dialog to close and project to load
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[role="dialog"]'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to spec editor
|
||||
const specNav = await getByTestId(page, "nav-spec");
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
|
||||
// Wait for spec view with the editor (not the empty state)
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
||||
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the content
|
||||
await setEditorContent(page, "hello world");
|
||||
|
||||
// Click save button
|
||||
await clickSaveButton(page);
|
||||
|
||||
// Refresh and verify persistence
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to spec editor
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
|
||||
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the content persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
});
|
||||
});
|
||||
272
apps/ui/tests/utils/api/client.ts
Normal file
272
apps/ui/tests/utils/api/client.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* API client utilities for making API calls in tests
|
||||
* Provides type-safe wrappers around common API operations
|
||||
*/
|
||||
|
||||
import { Page, APIResponse } from "@playwright/test";
|
||||
import { API_ENDPOINTS } from "../core/constants";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isNew?: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
success: boolean;
|
||||
worktrees: WorktreeInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeCreateResponse {
|
||||
success: boolean;
|
||||
worktree?: WorktreeInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeDeleteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
committed: boolean;
|
||||
branch?: string;
|
||||
commitHash?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommitResponse {
|
||||
success: boolean;
|
||||
result?: CommitResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SwitchBranchResult {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SwitchBranchResponse {
|
||||
success: boolean;
|
||||
result?: SwitchBranchResult;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ListBranchesResult {
|
||||
currentBranch: string;
|
||||
branches: BranchInfo[];
|
||||
}
|
||||
|
||||
export interface ListBranchesResponse {
|
||||
success: boolean;
|
||||
result?: ListBranchesResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree API Client
|
||||
// ============================================================================
|
||||
|
||||
export class WorktreeApiClient {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Create a new worktree
|
||||
*/
|
||||
async create(
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.create, {
|
||||
data: {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a worktree
|
||||
*/
|
||||
async delete(
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.delete, {
|
||||
data: {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees
|
||||
*/
|
||||
async list(
|
||||
projectPath: string,
|
||||
includeDetails: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.list, {
|
||||
data: {
|
||||
projectPath,
|
||||
includeDetails,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes in a worktree
|
||||
*/
|
||||
async commit(
|
||||
worktreePath: string,
|
||||
message: string
|
||||
): Promise<{ response: APIResponse; data: CommitResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.commit, {
|
||||
data: {
|
||||
worktreePath,
|
||||
message,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch branches in a worktree
|
||||
*/
|
||||
async switchBranch(
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.switchBranch, {
|
||||
data: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all branches
|
||||
*/
|
||||
async listBranches(
|
||||
worktreePath: string
|
||||
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.listBranches, {
|
||||
data: {
|
||||
worktreePath,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a WorktreeApiClient instance
|
||||
*/
|
||||
export function createWorktreeApiClient(page: Page): WorktreeApiClient {
|
||||
return new WorktreeApiClient(page);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions (for direct use without creating a client)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a worktree via API
|
||||
*/
|
||||
export async function apiCreateWorktree(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
|
||||
return new WorktreeApiClient(page).create(projectPath, branchName, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a worktree via API
|
||||
*/
|
||||
export async function apiDeleteWorktree(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
|
||||
return new WorktreeApiClient(page).delete(projectPath, worktreePath, deleteBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* List worktrees via API
|
||||
*/
|
||||
export async function apiListWorktrees(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
includeDetails: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
|
||||
return new WorktreeApiClient(page).list(projectPath, includeDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes in a worktree via API
|
||||
*/
|
||||
export async function apiCommitWorktree(
|
||||
page: Page,
|
||||
worktreePath: string,
|
||||
message: string
|
||||
): Promise<{ response: APIResponse; data: CommitResponse }> {
|
||||
return new WorktreeApiClient(page).commit(worktreePath, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch branches in a worktree via API
|
||||
*/
|
||||
export async function apiSwitchBranch(
|
||||
page: Page,
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
|
||||
return new WorktreeApiClient(page).switchBranch(worktreePath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches via API
|
||||
*/
|
||||
export async function apiListBranches(
|
||||
page: Page,
|
||||
worktreePath: string
|
||||
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
|
||||
return new WorktreeApiClient(page).listBranches(worktreePath);
|
||||
}
|
||||
59
apps/ui/tests/utils/components/autocomplete.ts
Normal file
59
apps/ui/tests/utils/components/autocomplete.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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> {
|
||||
const list = page.locator('[data-testid="category-autocomplete-list"]');
|
||||
return await list.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the category autocomplete dropdown to be visible
|
||||
*/
|
||||
export async function waitForCategoryAutocompleteList(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "category-autocomplete-list", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the category autocomplete dropdown to be hidden
|
||||
*/
|
||||
export async function waitForCategoryAutocompleteListHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
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, "-")}`;
|
||||
const option = page.locator(`[data-testid="${optionTestId}"]`);
|
||||
await option.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, "-")}`;
|
||||
return page.locator(`[data-testid="${optionTestId}"]`);
|
||||
}
|
||||
200
apps/ui/tests/utils/components/dialogs.ts
Normal file
200
apps/ui/tests/utils/components/dialogs.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
*/
|
||||
export async function isAddFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-feature-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the add context file dialog is visible
|
||||
*/
|
||||
export async function isAddContextDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-context-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit feature dialog is visible
|
||||
*/
|
||||
export async function isEditFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="edit-feature-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the edit feature dialog to be visible
|
||||
*/
|
||||
export async function waitForEditFeatureDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "edit-feature-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the edit feature description input/textarea element
|
||||
*/
|
||||
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> {
|
||||
const element = page.locator('[data-testid="edit-feature-description"]');
|
||||
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
|
||||
return tagName === "textarea";
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a specific feature
|
||||
*/
|
||||
export async function openEditFeatureDialog(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `edit-feature-${featureId}`);
|
||||
await waitForEditFeatureDialog(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the edit feature description field
|
||||
*/
|
||||
export async function fillEditFeatureDescription(
|
||||
page: Page,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const input = await getEditFeatureDescriptionInput(page);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the confirm edit feature button
|
||||
*/
|
||||
export async function confirmEditFeature(page: Page): Promise<void> {
|
||||
await clickElement(page, "confirm-edit-feature");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the delete confirmation dialog
|
||||
*/
|
||||
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> {
|
||||
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the delete confirmation dialog to appear
|
||||
*/
|
||||
export async function waitForDeleteConfirmationDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "delete-confirmation-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the delete confirmation dialog to be hidden
|
||||
*/
|
||||
export async function waitForDeleteConfirmationDialogHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the cancel delete button in the delete confirmation dialog
|
||||
*/
|
||||
export async function clickCancelDeleteButton(page: Page): Promise<void> {
|
||||
await clickElement(page, "cancel-delete-button");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the follow-up dialog is visible
|
||||
*/
|
||||
export async function isFollowUpDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="follow-up-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the follow-up dialog to be visible
|
||||
*/
|
||||
export async function waitForFollowUpDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "follow-up-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the follow-up dialog to be hidden
|
||||
*/
|
||||
export async function waitForFollowUpDialogHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project initialization dialog is visible
|
||||
*/
|
||||
export async function isProjectInitDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="project-init-dialog"]');
|
||||
return await dialog.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the project initialization dialog to appear
|
||||
*/
|
||||
export async function waitForProjectInitDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "project-init-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the project initialization dialog
|
||||
*/
|
||||
export async function closeProjectInitDialog(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('[data-testid="close-init-dialog"]');
|
||||
await closeButton.click();
|
||||
}
|
||||
104
apps/ui/tests/utils/components/modals.ts
Normal file
104
apps/ui/tests/utils/components/modals.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Check if the agent output modal is visible
|
||||
*/
|
||||
export async function isAgentOutputModalVisible(page: Page): Promise<boolean> {
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
return await modal.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent output modal to be visible
|
||||
*/
|
||||
export async function waitForAgentOutputModal(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "agent-output-modal", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent output modal to be hidden
|
||||
*/
|
||||
export async function waitForAgentOutputModalHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
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> {
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
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> {
|
||||
const modalDescription = page.locator(
|
||||
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
|
||||
);
|
||||
return await modalDescription.textContent().catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent output modal description element
|
||||
*/
|
||||
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> {
|
||||
const description = page.locator('[data-testid="agent-output-description"]');
|
||||
const scrollInfo = await description.evaluate((el) => {
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
isScrollable: el.scrollHeight > el.clientHeight,
|
||||
};
|
||||
});
|
||||
return scrollInfo.isScrollable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scroll dimensions of the agent output modal description
|
||||
*/
|
||||
export async function getAgentOutputDescriptionScrollDimensions(
|
||||
page: Page
|
||||
): Promise<{
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
maxHeight: string;
|
||||
overflowY: string;
|
||||
}> {
|
||||
const description = page.locator('[data-testid="agent-output-description"]');
|
||||
return await description.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
maxHeight: style.maxHeight,
|
||||
overflowY: style.overflowY,
|
||||
};
|
||||
});
|
||||
}
|
||||
87
apps/ui/tests/utils/components/toasts.ts
Normal file
87
apps/ui/tests/utils/components/toasts.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Wait for a toast notification with specific text to appear
|
||||
*/
|
||||
export async function waitForToast(
|
||||
page: Page,
|
||||
text: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an error toast to appear with specific text
|
||||
*/
|
||||
export async function waitForErrorToast(
|
||||
page: Page,
|
||||
titleText?: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
// Try multiple selectors for error toasts since Sonner versions may differ
|
||||
// 1. Try with data-type="error" attribute
|
||||
// 2. Fallback to any toast with the text (error styling might vary)
|
||||
const timeout = options?.timeout ?? 5000;
|
||||
|
||||
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();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
});
|
||||
return errorToast;
|
||||
} else {
|
||||
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
});
|
||||
return errorToast;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error toast is visible
|
||||
*/
|
||||
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"]';
|
||||
|
||||
const toast = page.locator(toastSelector).first();
|
||||
return await toast.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a success toast to appear with specific text
|
||||
*/
|
||||
export async function waitForSuccessToast(
|
||||
page: Page,
|
||||
titleText?: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
// Sonner toasts use data-sonner-toast and data-type="success" for success toasts
|
||||
const toastSelector = titleText
|
||||
? `[data-sonner-toast][data-type="success"]:has-text("${titleText}")`
|
||||
: '[data-sonner-toast][data-type="success"]';
|
||||
|
||||
const toast = page.locator(toastSelector).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
187
apps/ui/tests/utils/core/constants.ts
Normal file
187
apps/ui/tests/utils/core/constants.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Centralized constants for test utilities
|
||||
* This file contains all shared constants like URLs, timeouts, and selectors
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base URL for the API server
|
||||
*/
|
||||
export const API_BASE_URL = "http://localhost:3008";
|
||||
|
||||
/**
|
||||
* API endpoints for worktree operations
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
worktree: {
|
||||
create: `${API_BASE_URL}/api/worktree/create`,
|
||||
delete: `${API_BASE_URL}/api/worktree/delete`,
|
||||
list: `${API_BASE_URL}/api/worktree/list`,
|
||||
commit: `${API_BASE_URL}/api/worktree/commit`,
|
||||
switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`,
|
||||
listBranches: `${API_BASE_URL}/api/worktree/list-branches`,
|
||||
status: `${API_BASE_URL}/api/worktree/status`,
|
||||
info: `${API_BASE_URL}/api/worktree/info`,
|
||||
},
|
||||
fs: {
|
||||
browse: `${API_BASE_URL}/api/fs/browse`,
|
||||
read: `${API_BASE_URL}/api/fs/read`,
|
||||
write: `${API_BASE_URL}/api/fs/write`,
|
||||
},
|
||||
features: {
|
||||
list: `${API_BASE_URL}/api/features/list`,
|
||||
create: `${API_BASE_URL}/api/features/create`,
|
||||
update: `${API_BASE_URL}/api/features/update`,
|
||||
delete: `${API_BASE_URL}/api/features/delete`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Timeout Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default timeouts in milliseconds
|
||||
*/
|
||||
export const TIMEOUTS = {
|
||||
/** Default timeout for element visibility checks */
|
||||
default: 5000,
|
||||
/** Short timeout for quick checks */
|
||||
short: 2000,
|
||||
/** Medium timeout for standard operations */
|
||||
medium: 10000,
|
||||
/** Long timeout for slow operations */
|
||||
long: 30000,
|
||||
/** Extra long timeout for very slow operations */
|
||||
extraLong: 60000,
|
||||
/** Timeout for animations to complete */
|
||||
animation: 300,
|
||||
/** Small delay for UI to settle */
|
||||
settle: 500,
|
||||
/** Delay for network operations */
|
||||
network: 1000,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Test ID Selectors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common data-testid selectors organized by component/view
|
||||
*/
|
||||
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",
|
||||
|
||||
// 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",
|
||||
|
||||
// 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",
|
||||
|
||||
// Spec Editor
|
||||
specEditor: "spec-editor",
|
||||
|
||||
// File Browser Dialog
|
||||
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",
|
||||
|
||||
// Context View
|
||||
contextFileList: "context-file-list",
|
||||
addContextButton: "add-context-button",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// CSS Selectors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common CSS selectors for elements that don't have data-testid
|
||||
*/
|
||||
export const CSS_SELECTORS = {
|
||||
/** CodeMirror editor content area */
|
||||
codeMirrorContent: ".cm-content",
|
||||
/** Dialog elements */
|
||||
dialog: '[role="dialog"]',
|
||||
/** Sonner toast notifications */
|
||||
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]",
|
||||
/** Radix dialog overlay */
|
||||
dialogOverlay: "[data-radix-dialog-overlay]",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* localStorage keys used by the application
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
appStorage: "automaker-storage",
|
||||
setupStorage: "automaker-setup",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Branch Name Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sanitize a branch name to create a valid worktree directory name
|
||||
* @param branchName - The branch name to sanitize
|
||||
* @returns Sanitized name suitable for directory paths
|
||||
*/
|
||||
export function sanitizeBranchName(branchName: string): string {
|
||||
return branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default values used in test setup
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
projectName: "Test Project",
|
||||
projectPath: "/mock/test-project",
|
||||
theme: "dark" as const,
|
||||
maxConcurrency: 3,
|
||||
} as const;
|
||||
40
apps/ui/tests/utils/core/elements.ts
Normal file
40
apps/ui/tests/utils/core/elements.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get an element by its data-testid attribute
|
||||
*/
|
||||
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> {
|
||||
return page.locator(`button:has-text("${text}")`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category autocomplete input element
|
||||
*/
|
||||
export async function getCategoryAutocompleteInput(
|
||||
page: Page,
|
||||
testId: string = "feature-category-input"
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category autocomplete dropdown list
|
||||
*/
|
||||
export async function getCategoryAutocompleteList(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="category-autocomplete-list"]');
|
||||
}
|
||||
86
apps/ui/tests/utils/core/interactions.ts
Normal file
86
apps/ui/tests/utils/core/interactions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the platform-specific modifier + a key (e.g., Cmd+Enter or Ctrl+Enter)
|
||||
*/
|
||||
export async function pressModifierEnter(page: Page): Promise<void> {
|
||||
const modifier = getPlatformModifier();
|
||||
await page.keyboard.press(`${modifier}+Enter`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element by its data-testid attribute
|
||||
*/
|
||||
export async function clickElement(page: Page, testId: string): Promise<void> {
|
||||
const element = await getByTestId(page, testId);
|
||||
await element.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a button by its text content
|
||||
*/
|
||||
export async function clickButtonByText(
|
||||
page: Page,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
const button = await getButtonByText(page, text);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input field by its data-testid attribute
|
||||
*/
|
||||
export async function fillInput(
|
||||
page: Page,
|
||||
testId: string,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const input = await getByTestId(page, testId);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a keyboard shortcut key
|
||||
*/
|
||||
export async function pressShortcut(page: Page, key: string): Promise<void> {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a number key (0-9) on the keyboard
|
||||
*/
|
||||
export async function pressNumberKey(page: Page, num: number): Promise<void> {
|
||||
await page.keyboard.press(num.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an input element to test that shortcuts don't fire when typing
|
||||
*/
|
||||
export async function focusOnInput(page: Page, testId: string): Promise<void> {
|
||||
const input = page.locator(`[data-testid="${testId}"]`);
|
||||
await input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open dialog by pressing Escape
|
||||
* 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");
|
||||
// Wait for any dialog overlay to disappear
|
||||
await page
|
||||
.locator('[data-radix-dialog-overlay], [role="dialog"]')
|
||||
.waitFor({ state: "hidden", timeout: 5000 })
|
||||
.catch(() => {
|
||||
// Dialog may have already closed or not exist
|
||||
});
|
||||
}
|
||||
40
apps/ui/tests/utils/core/waiting.ts
Normal file
40
apps/ui/tests/utils/core/waiting.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with a specific data-testid to appear
|
||||
*/
|
||||
export async function waitForElement(
|
||||
page: Page,
|
||||
testId: string,
|
||||
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",
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with a specific data-testid to be hidden
|
||||
*/
|
||||
export async function waitForElementHidden(
|
||||
page: Page,
|
||||
testId: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const element = page.locator(`[data-testid="${testId}"]`);
|
||||
await element.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "hidden",
|
||||
});
|
||||
}
|
||||
62
apps/ui/tests/utils/features/kanban.ts
Normal file
62
apps/ui/tests/utils/features/kanban.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Perform a drag and drop operation that works with @dnd-kit
|
||||
* This uses explicit mouse movements with pointer events
|
||||
*
|
||||
* NOTE: dnd-kit requires careful timing for drag activation. In CI environments,
|
||||
* we need longer delays and more movement steps for reliable detection.
|
||||
*/
|
||||
export async function dragAndDropWithDndKit(
|
||||
page: Page,
|
||||
sourceLocator: Locator,
|
||||
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 });
|
||||
|
||||
// Small delay to ensure layout is stable
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const sourceBox = await sourceLocator.boundingBox();
|
||||
const targetBox = await targetLocator.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error("Could not find source or target element bounds");
|
||||
}
|
||||
|
||||
// Start drag from the center of the source element
|
||||
const startX = sourceBox.x + sourceBox.width / 2;
|
||||
const startY = sourceBox.y + sourceBox.height / 2;
|
||||
|
||||
// End drag at the center of the target element
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Move to source element first
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
// Press and hold - dnd-kit needs time to activate the drag sensor
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(300); // Longer delay for CI - dnd-kit activation threshold
|
||||
|
||||
// Move slightly first to trigger drag detection (dnd-kit has a distance threshold)
|
||||
const smallMoveX = startX + 10;
|
||||
const smallMoveY = startY + 10;
|
||||
await page.mouse.move(smallMoveX, smallMoveY, { steps: 3 });
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Now move to target with slower, more deliberate movement
|
||||
await page.mouse.move(endX, endY, { steps: 25 });
|
||||
|
||||
// Pause over target for drop detection
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Release
|
||||
await page.mouse.up();
|
||||
|
||||
// Allow time for the drop handler to process
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
114
apps/ui/tests/utils/features/skip-tests.ts
Normal file
114
apps/ui/tests/utils/features/skip-tests.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the skip tests checkbox element in the add feature dialog
|
||||
*/
|
||||
export async function getSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="skip-tests-checkbox"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the skip tests checkbox in the add feature dialog
|
||||
*/
|
||||
export async function toggleSkipTestsCheckbox(page: Page): Promise<void> {
|
||||
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
|
||||
await checkbox.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests checkbox is checked in the add feature dialog
|
||||
*/
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the edit skip tests checkbox element in the edit feature dialog
|
||||
*/
|
||||
export async function getEditSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the skip tests checkbox in the edit feature dialog
|
||||
*/
|
||||
export async function toggleEditSkipTestsCheckbox(page: Page): Promise<void> {
|
||||
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||
await checkbox.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests checkbox is checked in the edit feature dialog
|
||||
*/
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests badge is visible on a kanban card
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the skip tests badge element for a kanban card
|
||||
*/
|
||||
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> {
|
||||
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the manual verify button is visible for a feature
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the move back button for a verified skipTests feature
|
||||
*/
|
||||
export async function clickMoveBack(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the move back button is visible for a feature
|
||||
*/
|
||||
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);
|
||||
}
|
||||
36
apps/ui/tests/utils/features/timers.ts
Normal file
36
apps/ui/tests/utils/features/timers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
return card.locator('[data-testid="count-up-timer"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timer display text for a specific feature card
|
||||
*/
|
||||
export async function getTimerDisplayForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const timerDisplay = card.locator('[data-testid="timer-display"]');
|
||||
return await timerDisplay.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timer is visible for a specific feature
|
||||
*/
|
||||
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);
|
||||
}
|
||||
82
apps/ui/tests/utils/features/waiting-approval.ts
Normal file
82
apps/ui/tests/utils/features/waiting-approval.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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> {
|
||||
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> {
|
||||
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the follow-up button is visible for a feature
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the commit button for a waiting_approval feature
|
||||
*/
|
||||
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> {
|
||||
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the commit button is visible for a feature
|
||||
*/
|
||||
export async function isCommitButtonVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
return await button.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the waiting_approval kanban column
|
||||
*/
|
||||
export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the waiting_approval column is visible
|
||||
*/
|
||||
export async function isWaitingApprovalColumnVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
return await column.isVisible().catch(() => false);
|
||||
}
|
||||
82
apps/ui/tests/utils/files/drag-drop.ts
Normal file
82
apps/ui/tests/utils/files/drag-drop.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Simulate drag and drop of a file onto an element
|
||||
*/
|
||||
export async function simulateFileDrop(
|
||||
page: Page,
|
||||
targetSelector: string,
|
||||
fileName: string,
|
||||
fileContent: string,
|
||||
mimeType: string = "text/plain"
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ selector, content, name, mime }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
const file = new File([content], name, { type: mime });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Dispatch drag events
|
||||
target.dispatchEvent(
|
||||
new DragEvent("dragover", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate pasting an image from clipboard onto an element
|
||||
* Works across all OS (Windows, Linux, macOS)
|
||||
*/
|
||||
export async function simulateImagePaste(
|
||||
page: Page,
|
||||
targetSelector: string,
|
||||
imageBase64: string,
|
||||
mimeType: string = "image/png"
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ selector, base64, mime }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
// Convert base64 to Blob
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mime });
|
||||
|
||||
// Create a File from Blob
|
||||
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", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dataTransfer,
|
||||
});
|
||||
|
||||
target.dispatchEvent(clipboardEvent);
|
||||
},
|
||||
{ selector: targetSelector, base64: imageBase64, mime: mimeType }
|
||||
);
|
||||
}
|
||||
497
apps/ui/tests/utils/git/worktree.ts
Normal file
497
apps/ui/tests/utils/git/worktree.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Git worktree utilities for testing
|
||||
* 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";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FeatureData {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: string;
|
||||
branchName?: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the workspace root directory (internal use only)
|
||||
* Note: Also exported from project/fixtures.ts for broader use
|
||||
*/
|
||||
function getWorkspaceRoot(): string {
|
||||
const cwd = process.cwd();
|
||||
if (cwd.includes("apps/ui")) {
|
||||
return path.resolve(cwd, "../..");
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique temp directory path for tests
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected worktree path for a branch
|
||||
*/
|
||||
export function getWorktreePath(projectPath: string, branchName: string): string {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
return path.join(projectPath, ".worktrees", sanitizedName);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Repository Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a temporary git repository for testing
|
||||
*/
|
||||
export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
||||
// Create temp directory if it doesn't exist
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Initialize git repo
|
||||
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 });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync("git branch -M main", { cwd: tmpDir });
|
||||
|
||||
// Create .automaker directories
|
||||
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"), "[]");
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
await cleanupTestRepo(tmpDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a test git repository
|
||||
*/
|
||||
export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
||||
try {
|
||||
// Remove all worktrees first
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoPath,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
|
||||
const worktrees = stdout
|
||||
.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;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const worktreePath of worktrees) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: repoPath,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the repository
|
||||
fs.rmSync(repoPath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup test repo:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a temp directory and all its contents
|
||||
*/
|
||||
export function cleanupTempDir(tempDir: string): void {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a git command in a repository
|
||||
*/
|
||||
export async function gitExec(
|
||||
repoPath: string,
|
||||
command: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return execAsync(`git ${command}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git worktrees
|
||||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split("\n\n")
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
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 ", "");
|
||||
return path.normalize(worktreePath);
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git branches
|
||||
*/
|
||||
export async function listBranches(repoPath: string): Promise<string[]> {
|
||||
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git branch
|
||||
*/
|
||||
export async function createBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execAsync(`git branch ${branchName}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a git branch
|
||||
*/
|
||||
export async function checkoutBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execAsync(`git checkout ${branchName}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git worktree using git command directly
|
||||
*/
|
||||
export async function createWorktreeDirectly(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
worktreePath?: string
|
||||
): Promise<string> {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
|
||||
|
||||
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add and commit a file
|
||||
*/
|
||||
export async function commitFile(
|
||||
repoPath: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
fs.writeFileSync(path.join(repoPath, filePath), content);
|
||||
await execAsync(`git add "${filePath}"`, { cwd: repoPath });
|
||||
await execAsync(`git commit -m "${message}"`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest commit message
|
||||
*/
|
||||
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature File Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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");
|
||||
const featureDir = path.join(featuresDir, featureId);
|
||||
|
||||
fs.mkdirSync(featureDir, { recursive: true });
|
||||
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");
|
||||
|
||||
if (!fs.existsSync(featureFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
if (!fs.existsSync(featuresDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(featuresDir);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Setup for Tests
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to a test repo
|
||||
*/
|
||||
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",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
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
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-no-worktree",
|
||||
name: "Test Project (No Worktrees)",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: false, // Worktree feature DISABLED
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project that has STALE worktree data
|
||||
* 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> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-stale-worktree",
|
||||
name: "Stale Worktree Test Project",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
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" },
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wait Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wait for the board view to load
|
||||
* Navigates to /board first since the index route shows WelcomeView
|
||||
* Handles zustand store hydration timing (may show "no-project" briefly)
|
||||
*/
|
||||
export async function waitForBoardView(page: Page): Promise<void> {
|
||||
// Navigate directly to /board route (index route shows welcome view)
|
||||
const currentUrl = page.url();
|
||||
if (!currentUrl.includes('/board')) {
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Wait for either board-view (success) or board-view-no-project (store not hydrated yet)
|
||||
// Then poll until board-view appears (zustand hydrates from localStorage)
|
||||
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;
|
||||
},
|
||||
{ timeout: TIMEOUTS.long }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
}
|
||||
50
apps/ui/tests/utils/helpers/concurrency.ts
Normal file
50
apps/ui/tests/utils/helpers/concurrency.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the concurrency slider container
|
||||
*/
|
||||
export async function getConcurrencySliderContainer(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="concurrency-slider-container"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the concurrency slider
|
||||
*/
|
||||
export async function getConcurrencySlider(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="concurrency-slider"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the displayed concurrency value
|
||||
*/
|
||||
export async function getConcurrencyValue(page: Page): Promise<string | null> {
|
||||
const valueElement = page.locator('[data-testid="concurrency-value"]');
|
||||
return await valueElement.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the concurrency slider value by clicking on the slider track
|
||||
*/
|
||||
export async function setConcurrencyValue(
|
||||
page: Page,
|
||||
targetValue: number,
|
||||
min: number = 1,
|
||||
max: number = 10
|
||||
): Promise<void> {
|
||||
const slider = page.locator('[data-testid="concurrency-slider"]');
|
||||
const sliderBounds = await slider.boundingBox();
|
||||
|
||||
if (!sliderBounds) {
|
||||
throw new Error("Concurrency slider not found or not visible");
|
||||
}
|
||||
|
||||
// Calculate position for target value
|
||||
const percentage = (targetValue - min) / (max - min);
|
||||
const targetX = sliderBounds.x + sliderBounds.width * percentage;
|
||||
const centerY = sliderBounds.y + sliderBounds.height / 2;
|
||||
|
||||
// Click at the target position to set the value
|
||||
await page.mouse.click(targetX, centerY);
|
||||
}
|
||||
137
apps/ui/tests/utils/helpers/log-viewer.ts
Normal file
137
apps/ui/tests/utils/helpers/log-viewer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
|
||||
/**
|
||||
* Get the log viewer header element (contains type counts and expand/collapse buttons)
|
||||
*/
|
||||
export async function getLogViewerHeader(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-viewer-header"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the log viewer header is visible
|
||||
*/
|
||||
export async function isLogViewerHeaderVisible(page: Page): Promise<boolean> {
|
||||
const header = page.locator('[data-testid="log-viewer-header"]');
|
||||
return await header.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log entries container element
|
||||
*/
|
||||
export async function getLogEntriesContainer(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entries-container"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry by its type
|
||||
*/
|
||||
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> {
|
||||
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> {
|
||||
const entries = page.locator(`[data-testid="log-entry-${type}"]`);
|
||||
return await entries.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log type count badge by type
|
||||
*/
|
||||
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> {
|
||||
const badge = page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the expand all button in the log viewer
|
||||
*/
|
||||
export async function clickLogExpandAll(page: Page): Promise<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry badge element
|
||||
*/
|
||||
export async function getLogEntryBadge(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entry-badge"]').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any log entry badge is visible
|
||||
*/
|
||||
export async function isLogEntryBadgeVisible(page: Page): Promise<boolean> {
|
||||
const badge = page.locator('[data-testid="log-entry-badge"]').first();
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view mode toggle button (parsed/raw)
|
||||
*/
|
||||
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> {
|
||||
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> {
|
||||
const button = page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||
const classes = await button.getAttribute("class");
|
||||
return classes?.includes("text-purple-300") ?? false;
|
||||
}
|
||||
58
apps/ui/tests/utils/helpers/scroll.ts
Normal file
58
apps/ui/tests/utils/helpers/scroll.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Check if an element is scrollable (has scrollable content)
|
||||
*/
|
||||
export async function isElementScrollable(locator: Locator): Promise<boolean> {
|
||||
const scrollInfo = await locator.evaluate((el) => {
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
isScrollable: el.scrollHeight > el.clientHeight,
|
||||
};
|
||||
});
|
||||
return scrollInfo.isScrollable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll an element to the bottom
|
||||
*/
|
||||
export async function scrollToBottom(locator: Locator): Promise<void> {
|
||||
await locator.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scroll position of an element
|
||||
*/
|
||||
export async function getScrollPosition(
|
||||
locator: Locator
|
||||
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
|
||||
return await locator.evaluate((el) => ({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is visible within a scrollable container
|
||||
*/
|
||||
export async function isElementVisibleInScrollContainer(
|
||||
element: Locator,
|
||||
container: Locator
|
||||
): Promise<boolean> {
|
||||
const elementBox = await element.boundingBox();
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
if (!elementBox || !containerBox) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if element is within the visible area of the container
|
||||
return (
|
||||
elementBox.y >= containerBox.y &&
|
||||
elementBox.y + elementBox.height <= containerBox.y + containerBox.height
|
||||
);
|
||||
}
|
||||
49
apps/ui/tests/utils/index.ts
Normal file
49
apps/ui/tests/utils/index.ts
Normal file
@@ -0,0 +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";
|
||||
|
||||
// API utilities
|
||||
export * from "./api/client";
|
||||
|
||||
// Git utilities
|
||||
export * from "./git/worktree";
|
||||
|
||||
// Project utilities
|
||||
export * from "./project/setup";
|
||||
export * from "./project/fixtures";
|
||||
|
||||
// Navigation utilities
|
||||
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";
|
||||
|
||||
// Component utilities
|
||||
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";
|
||||
|
||||
// Helper utilities
|
||||
export * from "./helpers/scroll";
|
||||
export * from "./helpers/log-viewer";
|
||||
export * from "./helpers/concurrency";
|
||||
|
||||
// File utilities
|
||||
export * from "./files/drag-drop";
|
||||
116
apps/ui/tests/utils/navigation/views.ts
Normal file
116
apps/ui/tests/utils/navigation/views.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Navigate to the board/kanban view
|
||||
* Note: Navigates directly to /board since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToBoard(page: Page): Promise<void> {
|
||||
// Navigate directly to /board route
|
||||
await page.goto("/board");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the board view to be visible
|
||||
await waitForElement(page, "board-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the context view
|
||||
* Note: Navigates directly to /context since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToContext(page: Page): Promise<void> {
|
||||
// Navigate directly to /context route
|
||||
await page.goto("/context");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the context view to be visible
|
||||
await waitForElement(page, "context-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the spec view
|
||||
* Note: Navigates directly to /spec since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSpec(page: Page): Promise<void> {
|
||||
// Navigate directly to /spec route
|
||||
await page.goto("/spec");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the spec view to be visible
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the agent view
|
||||
* Note: Navigates directly to /agent since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToAgent(page: Page): Promise<void> {
|
||||
// Navigate directly to /agent route
|
||||
await page.goto("/agent");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the agent view to be visible
|
||||
await waitForElement(page, "agent-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the settings view
|
||||
* Note: Navigates directly to /settings since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSettings(page: Page): Promise<void> {
|
||||
// Navigate directly to /settings route
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the settings view to be visible
|
||||
await waitForElement(page, "settings-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the setup view directly
|
||||
* Note: This function uses setupFirstRun from project/setup to avoid circular dependency
|
||||
*/
|
||||
export async function navigateToSetup(page: Page): Promise<void> {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { setupFirstRun } = await import("../project/setup");
|
||||
await setupFirstRun(page);
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
await clickElement(page, navSelector);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current view from the URL or store (checks which view is active)
|
||||
*/
|
||||
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");
|
||||
return item ? JSON.parse(item) : null;
|
||||
});
|
||||
|
||||
return storage?.state?.currentView || null;
|
||||
}
|
||||
121
apps/ui/tests/utils/project/fixtures.ts
Normal file
121
apps/ui/tests/utils/project/fixtures.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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, "../..");
|
||||
}
|
||||
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");
|
||||
|
||||
// Original spec content for resetting between tests
|
||||
const ORIGINAL_SPEC_CONTENT = `<app_spec>
|
||||
<name>Test Project A</name>
|
||||
<description>A test fixture project for Playwright testing</description>
|
||||
<tech_stack>
|
||||
<item>TypeScript</item>
|
||||
<item>React</item>
|
||||
</tech_stack>
|
||||
</app_spec>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Reset the fixture's app_spec.txt to original content
|
||||
*/
|
||||
export function resetFixtureSpec(): void {
|
||||
const dir = path.dirname(SPEC_FILE_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the context directory to empty state
|
||||
*/
|
||||
export function resetContextDirectory(): void {
|
||||
if (fs.existsSync(CONTEXT_PATH)) {
|
||||
fs.rmSync(CONTEXT_PATH, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context file directly on disk (for test setup)
|
||||
*/
|
||||
export function createContextFileOnDisk(filename: string, content: string): void {
|
||||
const filePath = path.join(CONTEXT_PATH, filename);
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a context file exists on disk
|
||||
*/
|
||||
export function contextFileExistsOnDisk(filename: string): boolean {
|
||||
const filePath = path.join(CONTEXT_PATH, filename);
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to our test fixture
|
||||
* Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
|
||||
*/
|
||||
export async function setupProjectWithFixture(
|
||||
page: Page,
|
||||
projectPath: string = FIXTURE_PATH
|
||||
): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-fixture",
|
||||
name: "projectA",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture path
|
||||
*/
|
||||
export function getFixturePath(): string {
|
||||
return FIXTURE_PATH;
|
||||
}
|
||||
752
apps/ui/tests/utils/project/setup.ts
Normal file
752
apps/ui/tests/utils/project/setup.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Set up a mock project in localStorage to bypass the welcome screen
|
||||
* This simulates having opened a project before
|
||||
*/
|
||||
export async function setupMockProject(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with custom concurrency value
|
||||
*/
|
||||
export async function setupMockProjectWithConcurrency(
|
||||
page: Page,
|
||||
concurrency: number
|
||||
): Promise<void> {
|
||||
await page.addInitScript((maxConcurrency: number) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: maxConcurrency,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, concurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with specific running tasks to simulate concurrency limit
|
||||
*/
|
||||
export async function setupMockProjectAtConcurrencyLimit(
|
||||
page: Page,
|
||||
maxConcurrency: number = 1,
|
||||
runningTasks: string[] = ["running-task-1"]
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
maxConcurrency,
|
||||
runningTasks,
|
||||
}: {
|
||||
maxConcurrency: number;
|
||||
runningTasks: string[];
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: maxConcurrency,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: runningTasks,
|
||||
autoModeActivityLog: [],
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
},
|
||||
{ maxConcurrency, runningTasks }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features in different states
|
||||
*/
|
||||
export async function setupMockProjectWithFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
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",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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
|
||||
(window as any).__mockFeatures = mockFeatures;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with a feature context file
|
||||
* This simulates an agent having created context for a feature
|
||||
*/
|
||||
export async function setupMockProjectWithContextFile(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
contextContent: string = "# Agent Context\n\nPrevious implementation work..."
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
featureId,
|
||||
contextContent,
|
||||
}: {
|
||||
featureId: string;
|
||||
contextContent: string;
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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
|
||||
// Now uses features/{id}/agent-output.md path
|
||||
(window as any).__mockContextFile = {
|
||||
featureId,
|
||||
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
|
||||
content: contextContent,
|
||||
};
|
||||
},
|
||||
{ featureId, contextContent }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that have startedAt timestamps
|
||||
*/
|
||||
export async function setupMockProjectWithInProgressFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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
|
||||
(window as any).__mockFeatures = mockFeatures;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with a specific current view for route persistence testing
|
||||
*/
|
||||
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",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: currentView,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up an empty localStorage (no projects) to show welcome screen
|
||||
*/
|
||||
export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
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> {
|
||||
await page.addInitScript(() => {
|
||||
const mockProjects = [
|
||||
{
|
||||
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",
|
||||
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
|
||||
},
|
||||
];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: mockProjects,
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that have skipTests enabled
|
||||
*/
|
||||
export async function setupMockProjectWithSkipTestsFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
skipTests?: boolean;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock state with multiple projects
|
||||
*/
|
||||
export async function setupMockMultipleProjects(
|
||||
page: Page,
|
||||
projectCount: number = 3
|
||||
): Promise<void> {
|
||||
await page.addInitScript((count: number) => {
|
||||
const mockProjects = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
mockProjects.push({
|
||||
id: `test-project-${i + 1}`,
|
||||
name: `Test Project ${i + 1}`,
|
||||
path: `/mock/test-project-${i + 1}`,
|
||||
lastOpened: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: mockProjects,
|
||||
currentProject: mockProjects[0],
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, projectCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with agent output content in the context file
|
||||
*/
|
||||
export async function setupMockProjectWithAgentOutput(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
outputContent: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
featureId,
|
||||
outputContent,
|
||||
}: {
|
||||
featureId: string;
|
||||
outputContent: string;
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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
|
||||
(window as any).__mockContextFile = {
|
||||
featureId,
|
||||
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
|
||||
content: outputContent,
|
||||
};
|
||||
},
|
||||
{ featureId, outputContent }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that include waiting_approval status
|
||||
*/
|
||||
export async function setupMockProjectWithWaitingApprovalFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "waiting_approval" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
skipTests?: boolean;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
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;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the app store to show setup view (simulate first run)
|
||||
*/
|
||||
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");
|
||||
|
||||
// Set up the setup store state for first run
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: true,
|
||||
setupComplete: false,
|
||||
currentStep: "welcome",
|
||||
claudeCliStatus: null,
|
||||
claudeAuthStatus: null,
|
||||
claudeInstallProgress: {
|
||||
isInstalling: false,
|
||||
currentStep: "",
|
||||
progress: 0,
|
||||
output: [],
|
||||
},
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
|
||||
// Also set up app store to show setup view
|
||||
const appState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: [],
|
||||
autoModeActivityLog: [],
|
||||
currentView: "setup",
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(appState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the app to skip the setup wizard (setup already complete)
|
||||
*/
|
||||
export async function setupComplete(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with AI profiles for testing the profiles view
|
||||
* Includes default built-in profiles and optionally custom profiles
|
||||
*/
|
||||
export async function setupMockProjectWithProfiles(
|
||||
page: Page,
|
||||
options?: {
|
||||
customProfilesCount?: number;
|
||||
includeBuiltIn?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
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",
|
||||
description:
|
||||
"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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
// Generate custom profiles if requested
|
||||
const customProfiles = [];
|
||||
const customCount = opts?.customProfilesCount ?? 0;
|
||||
for (let i = 0; i < customCount; i++) {
|
||||
customProfiles.push({
|
||||
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,
|
||||
isBuiltIn: false,
|
||||
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 mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "", openai: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: aiProfiles,
|
||||
features: [],
|
||||
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));
|
||||
|
||||
// Also mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, options);
|
||||
}
|
||||
98
apps/ui/tests/utils/views/agent.ts
Normal file
98
apps/ui/tests/utils/views/agent.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Get the session list element
|
||||
*/
|
||||
export async function getSessionList(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="session-list"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new session button
|
||||
*/
|
||||
export async function getNewSessionButton(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="new-session-button"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the new session button
|
||||
*/
|
||||
export async function clickNewSessionButton(page: Page): Promise<void> {
|
||||
const button = await getNewSessionButton(page);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session item by its ID
|
||||
*/
|
||||
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> {
|
||||
const button = page.locator(`[data-testid="archive-session-${sessionId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the no session placeholder is visible
|
||||
*/
|
||||
export async function isNoSessionPlaceholderVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
|
||||
return await placeholder.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the no session placeholder to be visible
|
||||
*/
|
||||
export async function waitForNoSessionPlaceholder(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "no-session-placeholder", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the message list is visible (indicates a session is selected)
|
||||
*/
|
||||
export async function isMessageListVisible(page: Page): Promise<boolean> {
|
||||
const messageList = page.locator('[data-testid="message-list"]');
|
||||
return await messageList.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-"]'
|
||||
);
|
||||
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> {
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
257
apps/ui/tests/utils/views/board.ts
Normal file
257
apps/ui/tests/utils/views/board.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get a kanban card by feature ID
|
||||
*/
|
||||
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> {
|
||||
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> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const box = await column.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kanban column has CSS columns (masonry) layout
|
||||
*/
|
||||
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 columnCount = await contentDiv.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.columnCount;
|
||||
});
|
||||
|
||||
return columnCount === "2";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a kanban card from one column to another
|
||||
*/
|
||||
export async function dragKanbanCard(
|
||||
page: Page,
|
||||
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}"]`
|
||||
);
|
||||
|
||||
// Perform drag and drop
|
||||
await dragHandle.dragTo(targetColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the view output button on a kanban card
|
||||
*/
|
||||
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}"]`
|
||||
);
|
||||
|
||||
if (await runningBtn.isVisible()) {
|
||||
await runningBtn.click();
|
||||
} else if (await inProgressBtn.isVisible()) {
|
||||
await inProgressBtn.click();
|
||||
} else {
|
||||
throw new Error(`View output button not found for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the drag handle is visible for a specific feature card
|
||||
*/
|
||||
export async function isDragHandleVisibleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
return await dragHandle.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the drag handle element for a specific feature card
|
||||
*/
|
||||
export async function getDragHandleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Feature Dialog
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click the add feature button
|
||||
*/
|
||||
export async function clickAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="add-feature-button"]');
|
||||
await page.waitForSelector('[data-testid="add-feature-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the add feature dialog
|
||||
*/
|
||||
export async function fillAddFeatureDialog(
|
||||
page: Page,
|
||||
description: string,
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
// Fill description (using the dropzone textarea)
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(description);
|
||||
|
||||
// Fill branch if provided (it's a combobox autocomplete)
|
||||
if (options?.branch) {
|
||||
// First, select "Other branch" radio option if not already selected
|
||||
const otherBranchRadio = page
|
||||
.locator('[data-testid="feature-radio-group"]')
|
||||
.locator('[id="feature-other"]');
|
||||
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.click();
|
||||
// Wait for the popover to open
|
||||
await page.waitForTimeout(300);
|
||||
// Type in the command input
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.branch);
|
||||
// Press Enter to select/create the branch
|
||||
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"]'
|
||||
);
|
||||
await categoryButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.category);
|
||||
await commandInput.press("Enter");
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the add feature dialog
|
||||
*/
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a feature with all steps in one call
|
||||
*/
|
||||
export async function addFeature(
|
||||
page: Page,
|
||||
description: string,
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, description, options);
|
||||
await confirmAddFeature(page);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Selector
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the worktree selector element
|
||||
*/
|
||||
export async function getWorktreeSelector(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="worktree-selector"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"),
|
||||
});
|
||||
await branchButton.click();
|
||||
await page.waitForTimeout(500); // Wait for UI to update
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected branch in the worktree selector
|
||||
*/
|
||||
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"]'
|
||||
);
|
||||
const text = await selectedButton.textContent().catch(() => null);
|
||||
return text?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"),
|
||||
});
|
||||
return await branchButton.isVisible().catch(() => false);
|
||||
}
|
||||
185
apps/ui/tests/utils/views/context.ts
Normal file
185
apps/ui/tests/utils/views/context.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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
|
||||
*/
|
||||
export async function getContextFileList(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="context-file-list"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a context file in the list
|
||||
*/
|
||||
export async function clickContextFile(
|
||||
page: Page,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
||||
await fileButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context editor element
|
||||
*/
|
||||
export async function getContextEditor(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="context-editor"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context editor content
|
||||
*/
|
||||
export async function getContextEditorContent(page: Page): Promise<string> {
|
||||
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");
|
||||
await editor.fill(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text context file via the UI
|
||||
*/
|
||||
export async function createContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image context file via the UI
|
||||
*/
|
||||
export async function createContextImage(
|
||||
page: Page,
|
||||
filename: string,
|
||||
imagePath: string
|
||||
): Promise<void> {
|
||||
await openAddContextFileDialog(page);
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current context file
|
||||
*/
|
||||
export async function saveContextFile(page: Page): Promise<void> {
|
||||
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"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle markdown preview mode
|
||||
*/
|
||||
export async function toggleContextPreviewMode(page: Page): Promise<void> {
|
||||
await clickElement(page, "toggle-preview-mode");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific file to appear in the context file list
|
||||
*/
|
||||
export async function waitForContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
const locator = await getByTestId(page, `context-file-${filename}`);
|
||||
await locator.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a file in the list and wait for it to be selected (toolbar visible)
|
||||
* Uses JavaScript click to ensure React event handler fires
|
||||
*/
|
||||
export async function selectContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
const fileButton = await getByTestId(page, `context-file-${filename}`);
|
||||
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");
|
||||
await expect(deleteButton).toBeVisible({
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for file content panel to load (either editor, preview, or image)
|
||||
*/
|
||||
export async function waitForFileContentToLoad(page: Page): Promise<void> {
|
||||
// Wait for either the editor, preview, or image to appear
|
||||
await page.waitForSelector(
|
||||
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]',
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch from preview mode to edit mode for markdown files
|
||||
* Markdown files open in preview mode by default, this helper switches to edit mode
|
||||
*/
|
||||
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 isPreview = await markdownPreview.isVisible().catch(() => false);
|
||||
|
||||
if (isPreview) {
|
||||
await clickElement(page, "toggle-preview-mode");
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
572
apps/ui/tests/utils/views/profiles.ts
Normal file
572
apps/ui/tests/utils/views/profiles.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
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");
|
||||
|
||||
// Wait for profiles view to be visible
|
||||
await page.waitForSelector('[data-testid="profiles-view"]', {
|
||||
state: "visible",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile List Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a specific profile card by ID
|
||||
*/
|
||||
export async function getProfileCard(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<Locator> {
|
||||
return getByTestId(page, `profile-card-${profileId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all profile cards (both built-in and custom)
|
||||
*/
|
||||
export async function getProfileCards(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid^="profile-card-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only custom profile cards
|
||||
*/
|
||||
export async function getCustomProfiles(page: Page): Promise<Locator> {
|
||||
// Custom profiles don't have the "Built-in" badge
|
||||
return page.locator('[data-testid^="profile-card-"]').filter({
|
||||
hasNot: page.locator('text="Built-in"'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only built-in profile cards
|
||||
*/
|
||||
export async function getBuiltInProfiles(page: Page): Promise<Locator> {
|
||||
// Built-in profiles have the lock icon and "Built-in" text
|
||||
return page.locator('[data-testid^="profile-card-"]:has-text("Built-in")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of custom profiles
|
||||
*/
|
||||
export async function countCustomProfiles(page: Page): Promise<number> {
|
||||
const customProfiles = await getCustomProfiles(page);
|
||||
return customProfiles.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of built-in profiles
|
||||
*/
|
||||
export async function countBuiltInProfiles(page: Page): Promise<number> {
|
||||
const builtInProfiles = await getBuiltInProfiles(page);
|
||||
return await builtInProfiles.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom profile IDs
|
||||
*/
|
||||
export async function getCustomProfileIds(page: Page): Promise<string[]> {
|
||||
const allCards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
const customIds: string[] = [];
|
||||
|
||||
for (const card of allCards) {
|
||||
const builtInText = card.locator('text="Built-in"');
|
||||
const isBuiltIn = (await builtInText.count()) > 0;
|
||||
if (!isBuiltIn) {
|
||||
const testId = await card.getAttribute("data-testid");
|
||||
if (testId) {
|
||||
// Extract ID from "profile-card-{id}"
|
||||
const profileId = testId.replace("profile-card-", "");
|
||||
customIds.push(profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return customIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first custom profile ID (useful after creating a profile)
|
||||
*/
|
||||
export async function getFirstCustomProfileId(page: Page): Promise<string | null> {
|
||||
const ids = await getCustomProfileIds(page);
|
||||
return ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the empty state card to create a new profile
|
||||
*/
|
||||
export async function clickEmptyState(page: Page): Promise<void> {
|
||||
const emptyState = page.locator(
|
||||
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
|
||||
);
|
||||
await emptyState.click();
|
||||
await waitForElement(page, "add-profile-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the profile form with data
|
||||
*/
|
||||
export async function fillProfileForm(
|
||||
page: Page,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (data.name !== undefined) {
|
||||
await fillProfileName(page, data.name);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
await fillProfileDescription(page, data.description);
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
await selectIcon(page, data.icon);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
await selectModel(page, data.model);
|
||||
}
|
||||
if (data.thinkingLevel !== undefined) {
|
||||
await selectThinkingLevel(page, data.thinkingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save button to create/update a profile
|
||||
*/
|
||||
export async function saveProfile(page: Page): Promise<void> {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the cancel button in the profile dialog
|
||||
*/
|
||||
export async function cancelProfileDialog(page: Page): Promise<void> {
|
||||
// Look for cancel button in dialog footer
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the edit button for a specific profile
|
||||
*/
|
||||
export async function clickEditProfile(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `edit-profile-${profileId}`);
|
||||
await waitForElement(page, "edit-profile-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the delete button for a specific profile
|
||||
*/
|
||||
export async function clickDeleteProfile(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `delete-profile-${profileId}`);
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel profile deletion
|
||||
*/
|
||||
export async function cancelDeleteProfile(page: Page): Promise<void> {
|
||||
await clickElement(page, "cancel-delete-button");
|
||||
await waitForElementHidden(page, "delete-profile-confirm-dialog");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Field Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fill the profile name field
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an icon for the profile
|
||||
* @param iconName - Name of the icon: Brain, Zap, Scale, Cpu, Rocket, Sparkles
|
||||
*/
|
||||
export async function selectIcon(page: Page, iconName: string): Promise<void> {
|
||||
await clickElement(page, `icon-select-${iconName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a model for the profile
|
||||
* @param modelId - Model ID: haiku, sonnet, opus
|
||||
*/
|
||||
export async function selectModel(page: Page, modelId: string): Promise<void> {
|
||||
await clickElement(page, `model-select-${modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await clickElement(page, `thinking-select-${level}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected icon
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected model
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected thinking level
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialog Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if the add profile dialog is open
|
||||
*/
|
||||
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
|
||||
const dialog = await getByTestId(page, "add-profile-dialog");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit profile dialog is open
|
||||
*/
|
||||
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
|
||||
const dialog = await getByTestId(page, "edit-profile-dialog");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any profile dialog to close
|
||||
* This ensures all dialog animations complete before proceeding
|
||||
*/
|
||||
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(
|
||||
() => {}
|
||||
),
|
||||
]);
|
||||
|
||||
// Also wait for any Radix dialog overlay to be removed (handles animation)
|
||||
await page
|
||||
.locator('[data-radix-dialog-overlay]')
|
||||
.waitFor({ state: "hidden", timeout: 2000 })
|
||||
.catch(() => {
|
||||
// Overlay may not exist
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Card Inspection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the profile name from a card
|
||||
*/
|
||||
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() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile description from a card
|
||||
*/
|
||||
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() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile model badge text from a card
|
||||
*/
|
||||
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() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile thinking level badge text from a card
|
||||
*/
|
||||
export async function getProfileThinkingLevel(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<string | null> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
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() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile has the built-in badge
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit button is visible for a profile
|
||||
*/
|
||||
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 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the delete button is visible for a profile
|
||||
*/
|
||||
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 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Drag & Drop
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Drag a profile from one position to another
|
||||
* Uses the drag handle and dnd-kit library pattern
|
||||
*
|
||||
* Note: dnd-kit requires pointer events with specific timing for drag recognition.
|
||||
* Manual mouse operations are needed because Playwright's dragTo doesn't work
|
||||
* reliably with dnd-kit's pointer-based drag detection.
|
||||
*
|
||||
* @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> {
|
||||
// Get all profile cards
|
||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
|
||||
if (fromIndex >= cards.length || toIndex >= cards.length) {
|
||||
throw new Error(
|
||||
`Invalid drag indices: fromIndex=${fromIndex}, toIndex=${toIndex}, total=${cards.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const fromCard = cards[fromIndex];
|
||||
const toCard = cards[toIndex];
|
||||
|
||||
// Get the drag handle within the source card
|
||||
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
|
||||
|
||||
// Ensure drag handle is visible and ready
|
||||
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");
|
||||
}
|
||||
|
||||
// Start position (center of drag handle)
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
// End position (center of target card)
|
||||
const endX = toBox.x + toBox.width / 2;
|
||||
const endY = toBox.y + toBox.height / 2;
|
||||
|
||||
// Perform manual drag operation
|
||||
// dnd-kit needs pointer events in a specific sequence
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
|
||||
// dnd-kit requires a brief hold before recognizing the drag gesture
|
||||
// This is a library requirement, not an arbitrary timeout
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// Move to target in steps for smoother drag recognition
|
||||
await page.mouse.move(endX, endY, { steps: 10 });
|
||||
|
||||
// Brief pause before drop
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for reorder animation to complete
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current order of all profile IDs
|
||||
* Returns array of profile IDs in display order
|
||||
*/
|
||||
export async function getProfileOrder(page: Page): Promise<string[]> {
|
||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const card of cards) {
|
||||
const testId = await card.getAttribute("data-testid");
|
||||
if (testId) {
|
||||
// Extract profile ID from data-testid="profile-card-{id}"
|
||||
const profileId = testId.replace("profile-card-", "");
|
||||
ids.push(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Header Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click the "Refresh Defaults" button
|
||||
*/
|
||||
export async function clickRefreshDefaults(page: Page): Promise<void> {
|
||||
await clickElement(page, "refresh-profiles-button");
|
||||
}
|
||||
8
apps/ui/tests/utils/views/settings.ts
Normal file
8
apps/ui/tests/utils/views/settings.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the settings view scrollable content area
|
||||
*/
|
||||
export async function getSettingsContentArea(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="settings-view"] .overflow-y-auto');
|
||||
}
|
||||
75
apps/ui/tests/utils/views/setup.ts
Normal file
75
apps/ui/tests/utils/views/setup.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { getByTestId } from "../core/elements";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
import { setupFirstRun } from "../project/setup";
|
||||
|
||||
/**
|
||||
* Wait for setup view to be visible
|
||||
*/
|
||||
export async function waitForSetupView(page: Page): Promise<Locator> {
|
||||
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");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click continue on Claude setup step
|
||||
*/
|
||||
export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "claude-next-button");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click finish on setup complete step
|
||||
*/
|
||||
export async function clickSetupFinish(page: Page): Promise<void> {
|
||||
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> {
|
||||
// Click "Use Anthropic API Key Instead" 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");
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save 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> {
|
||||
// Click "Enter OpenAI API 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");
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save button
|
||||
const saveButton = await getByTestId(page, "save-openai-key-button");
|
||||
await saveButton.click();
|
||||
}
|
||||
118
apps/ui/tests/utils/views/spec-editor.ts
Normal file
118
apps/ui/tests/utils/views/spec-editor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
import { navigateToSpec } from "../navigation/views";
|
||||
|
||||
/**
|
||||
* Get the spec editor element
|
||||
*/
|
||||
export async function getSpecEditor(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="spec-editor"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the spec editor content
|
||||
*/
|
||||
export async function getSpecEditorContent(page: Page): Promise<string> {
|
||||
const editor = await getSpecEditor(page);
|
||||
return await editor.inputValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the spec editor content
|
||||
*/
|
||||
export async function setSpecEditorContent(
|
||||
page: Page,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const editor = await getSpecEditor(page);
|
||||
await editor.fill(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save spec button
|
||||
*/
|
||||
export async function clickSaveSpec(page: Page): Promise<void> {
|
||||
await clickElement(page, "save-spec");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the reload spec button
|
||||
*/
|
||||
export async function clickReloadSpec(page: Page): Promise<void> {
|
||||
await clickElement(page, "reload-spec");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the spec view path display shows the correct .automaker path
|
||||
*/
|
||||
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();
|
||||
return await pathElement.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the spec editor view
|
||||
*/
|
||||
export async function navigateToSpecEditor(page: Page): Promise<void> {
|
||||
await navigateToSpec(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CodeMirror editor content
|
||||
*/
|
||||
export async function getEditorContent(page: Page): Promise<string> {
|
||||
// CodeMirror uses a contenteditable div with class .cm-content
|
||||
const content = await page
|
||||
.locator('[data-testid="spec-editor"] .cm-content')
|
||||
.textContent();
|
||||
return content || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the CodeMirror editor content by selecting all and typing
|
||||
*/
|
||||
export async function setEditorContent(page: Page, content: string): Promise<void> {
|
||||
// Click on the editor to focus it
|
||||
const editor = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||
await editor.click();
|
||||
|
||||
// Wait for focus
|
||||
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");
|
||||
|
||||
// Wait for selection
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Delete the selected content first
|
||||
await page.keyboard.press("Backspace");
|
||||
|
||||
// Wait for deletion
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Type the new content
|
||||
await page.keyboard.type(content, { delay: 10 });
|
||||
|
||||
// Wait for typing to complete
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save button
|
||||
*/
|
||||
export async function clickSaveButton(page: Page): Promise<void> {
|
||||
const saveButton = page.locator('[data-testid="save-spec"]');
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for the button text to change to "Saved" indicating save is complete
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector('[data-testid="save-spec"]');
|
||||
return btn?.textContent?.includes("Saved");
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
2909
apps/ui/tests/worktree-integration.spec.ts
Normal file
2909
apps/ui/tests/worktree-integration.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user