diff --git a/.gitignore b/.gitignore index 1d01c7c2..590e1b67 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ dist/ /.automaker/* /.automaker/ -/old \ No newline at end of file +/logs \ No newline at end of file diff --git a/apps/app/src/components/views/context-view.tsx b/apps/app/src/components/views/context-view.tsx index 424144c3..42457344 100644 --- a/apps/app/src/components/views/context-view.tsx +++ b/apps/app/src/components/views/context-view.tsx @@ -393,6 +393,7 @@ export function ContextView() { className="flex-1 flex overflow-hidden" onDrop={handleDrop} onDragOver={handleDragOver} + data-testid="context-drop-zone" > {/* Left Panel - File List */}
diff --git a/apps/app/tests/context-view.spec.ts b/apps/app/tests/context-view.spec.ts new file mode 100644 index 00000000..dbc36d12 --- /dev/null +++ b/apps/app/tests/context-view.spec.ts @@ -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/app/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); + }); +}); diff --git a/apps/app/tests/spec-editor-persistence.spec.ts b/apps/app/tests/spec-editor-persistence.spec.ts index 06dc78e5..72d5b504 100644 --- a/apps/app/tests/spec-editor-persistence.spec.ts +++ b/apps/app/tests/spec-editor-persistence.spec.ts @@ -1,157 +1,18 @@ -import { test, expect, Page } from "@playwright/test"; -import * as fs from "fs"; -import * as path from "path"; - -// Resolve the workspace root - handle both running from apps/app and from root -function getWorkspaceRoot(): string { - const cwd = process.cwd(); - if (cwd.includes("apps/app")) { - 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"); - -// Original spec content for resetting between tests -const ORIGINAL_SPEC_CONTENT = ` - Test Project A - A test fixture project for Playwright testing - - TypeScript - React - - -`; - -/** - * Reset the fixture's app_spec.txt to original content - */ -function resetFixtureSpec() { - const dir = path.dirname(SPEC_FILE_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT); -} - -/** - * 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 - */ -async function setupProjectWithFixture(page: Page, projectPath: string) { - await page.addInitScript((path: string) => { - const mockProject = { - id: "test-project-fixture", - name: "projectA", - path: path, - 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: 0, - }; - - 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: 0, - }; - localStorage.setItem("automaker-setup", JSON.stringify(setupState)); - }, projectPath); -} - -/** - * Navigate to spec editor via sidebar - */ -async function navigateToSpecEditor(page: Page) { - // Click on the Spec Editor nav item in the sidebar - const specNavButton = page.locator('[data-testid="nav-spec"]'); - await specNavButton.waitFor({ state: "visible", timeout: 10000 }); - await specNavButton.click(); - - // Wait for the spec view to be visible - await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 }); -} - -/** - * Get the CodeMirror editor content - */ -async function getEditorContent(page: Page): Promise { - // 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 - */ -async function setEditorContent(page: Page, content: string) { - // 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 - */ -async function clickSaveButton(page: Page) { - 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 } - ); -} +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 () => { @@ -168,31 +29,29 @@ test.describe("Spec Editor Persistence", () => { page, }) => { // Use the resolved fixture path - const fixturePath = 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 page.waitForLoadState("networkidle"); + await waitForNetworkIdle(page); // Step 3: Verify we're on the dashboard with the project loaded // The sidebar should show the project selector - const sidebar = page.locator('[data-testid="sidebar"]'); + 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 = page.locator('[data-testid="spec-editor"]'); + 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 page.waitForSelector('[data-testid="spec-editor"] .cm-content', { - timeout: 10000, - }); + await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 }); // Small delay to ensure editor is fully initialized await page.waitForTimeout(500); @@ -205,19 +64,18 @@ test.describe("Spec Editor Persistence", () => { // Step 9: Refresh the page await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForNetworkIdle(page); // Step 10: Navigate back to the spec editor // After reload, we need to wait for the app to initialize - await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 }); + await waitForElement(page, "sidebar", { timeout: 10000 }); // Navigate to spec editor again await navigateToSpecEditor(page); // Wait for CodeMirror to be ready - await page.waitForSelector('[data-testid="spec-editor"] .cm-content', { - timeout: 10000, - }); + 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); @@ -269,16 +127,14 @@ test.describe("Spec Editor Persistence", () => { // Navigate to the app await page.goto("/"); - await page.waitForLoadState("networkidle"); + await waitForNetworkIdle(page); // Wait for the sidebar to be visible - const sidebar = page.locator('[data-testid="sidebar"]'); + const sidebar = await getByTestId(page, "sidebar"); await sidebar.waitFor({ state: "visible", timeout: 10000 }); // Click the Open Project button - const openProjectButton = page.locator( - '[data-testid="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 @@ -286,7 +142,7 @@ test.describe("Spec Editor Persistence", () => { .catch(() => false); if (isButtonVisible) { - await openProjectButton.click(); + await clickElement(page, "open-project-button"); // The file browser dialog should open // Note: In web mode, this might use the FileBrowserDialog component @@ -341,7 +197,7 @@ test.describe("Spec Editor - Full Open Project Flow", () => { }) => { // Navigate to app first await page.goto("/"); - await page.waitForLoadState("networkidle"); + 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 @@ -378,17 +234,15 @@ test.describe("Spec Editor - Full Open Project Flow", () => { // Reload to apply the localStorage state await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForNetworkIdle(page); // Wait for sidebar - await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 }); + await waitForElement(page, "sidebar", { timeout: 10000 }); // Click the Open Project button - const openProjectButton = page.locator( - '[data-testid="open-project-button"]' - ); + const openProjectButton = await getByTestId(page, "open-project-button"); await openProjectButton.waitFor({ state: "visible", timeout: 10000 }); - await openProjectButton.click(); + await clickElement(page, "open-project-button"); // Wait for the file browser dialog to open const dialogTitle = page.locator('text="Select Project Directory"'); @@ -401,15 +255,14 @@ test.describe("Spec Editor - Full Open Project Flow", () => { ); // Use the path input to directly navigate to the fixture directory - const pathInput = page.locator('[data-testid="path-input"]'); + 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 pathInput.fill(FIXTURE_PATH); + await fillInput(page, "path-input", getFixturePath()); // Click the Go button to navigate to the path - const goButton = page.locator('[data-testid="go-to-path-button"]'); - await goButton.click(); + await clickElement(page, "go-to-path-button"); // Wait for loading to complete await page.waitForFunction( @@ -435,15 +288,14 @@ test.describe("Spec Editor - Full Open Project Flow", () => { await page.waitForTimeout(500); // Navigate to spec editor - const specNav = page.locator('[data-testid="nav-spec"]'); + const specNav = await getByTestId(page, "nav-spec"); await specNav.waitFor({ state: "visible", timeout: 10000 }); - await specNav.click(); + await clickElement(page, "nav-spec"); // Wait for spec view with the editor (not the empty state) - await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="spec-editor"] .cm-content', { - timeout: 10000, - }); + await waitForElement(page, "spec-view", { timeout: 10000 }); + const specEditorForOpenFlow = await getByTestId(page, "spec-editor"); + await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 }); await page.waitForTimeout(500); // Edit the content @@ -454,15 +306,14 @@ test.describe("Spec Editor - Full Open Project Flow", () => { // Refresh and verify persistence await page.reload(); - await page.waitForLoadState("networkidle"); + await waitForNetworkIdle(page); // Navigate back to spec editor await specNav.waitFor({ state: "visible", timeout: 10000 }); - await specNav.click(); + await clickElement(page, "nav-spec"); - await page.waitForSelector('[data-testid="spec-editor"] .cm-content', { - timeout: 10000, - }); + const specEditorAfterRefresh = await getByTestId(page, "spec-editor"); + await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 }); await page.waitForTimeout(500); // Verify the content persisted diff --git a/apps/app/tests/utils.ts b/apps/app/tests/utils.ts deleted file mode 100644 index fe6b0291..00000000 --- a/apps/app/tests/utils.ts +++ /dev/null @@ -1,2569 +0,0 @@ -import { Page, Locator, expect } from "@playwright/test"; - -/** - * Get an element by its data-testid attribute - */ -export async function getByTestId( - page: Page, - testId: string -): Promise { - return page.locator(`[data-testid="${testId}"]`); -} - -/** - * 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 { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }); -} - -/** - * Click an element by its data-testid attribute - */ -export async function clickElement(page: Page, testId: string): Promise { - const element = await getByTestId(page, testId); - await element.click(); -} - -/** - * 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 { - 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 { - const element = page.locator(`[data-testid="${testId}"]`); - await element.waitFor({ - timeout: options?.timeout ?? 5000, - state: "hidden", - }); -} - -/** - * Get a button by its text content - */ -export async function getButtonByText( - page: Page, - text: string -): Promise { - return page.locator(`button:has-text("${text}")`); -} - -/** - * Click a button by its text content - */ -export async function clickButtonByText( - page: Page, - text: string -): Promise { - 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 { - const input = await getByTestId(page, testId); - await input.fill(value); -} - -/** - * Navigate to the board/kanban view - */ -export async function navigateToBoard(page: Page): Promise { - await page.goto("/"); - - // Wait for the page to load - await page.waitForLoadState("networkidle"); - - // Check if we're on the board view already - const boardView = page.locator('[data-testid="board-view"]'); - const isOnBoard = await boardView.isVisible().catch(() => false); - - if (!isOnBoard) { - // Try to click on a recent project first (from welcome screen) - const recentProject = page.locator('p:has-text("Test Project")').first(); - if (await recentProject.isVisible().catch(() => false)) { - await recentProject.click(); - await page.waitForTimeout(200); - } - - // Then click on Kanban Board nav button to ensure we're on the board - const kanbanNav = page.locator('[data-testid="nav-board"]'); - if (await kanbanNav.isVisible().catch(() => false)) { - await kanbanNav.click(); - } - } - - // Wait for the board view to be visible - await waitForElement(page, "board-view", { timeout: 10000 }); -} - -/** - * Check if the agent output modal is visible - */ -export async function isAgentOutputModalVisible(page: Page): Promise { - 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 { - 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 { - await waitForElementHidden(page, "agent-output-modal", options); -} - -/** - * Drag a kanban card from one column to another - */ -export async function dragKanbanCard( - page: Page, - featureId: string, - targetColumnId: string -): Promise { - 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); -} - -/** - * Get a kanban card by feature ID - */ -export async function getKanbanCard( - page: Page, - featureId: string -): Promise { - return page.locator(`[data-testid="kanban-card-${featureId}"]`); -} - -/** - * Click the view output button on a kanban card - */ -export async function clickViewOutput( - page: Page, - featureId: string -): Promise { - // 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}`); - } -} - -/** - * Perform a drag and drop operation that works with @dnd-kit - * This uses explicit mouse movements with pointer events - */ -export async function dragAndDropWithDndKit( - page: Page, - sourceLocator: Locator, - targetLocator: Locator -): Promise { - 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; - - // Perform the drag and drop with pointer events - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.waitForTimeout(150); // Give dnd-kit time to recognize the drag - await page.mouse.move(endX, endY, { steps: 15 }); - await page.waitForTimeout(100); // Allow time for drop detection - await page.mouse.up(); -} - -/** - * Get the concurrency slider container - */ -export async function getConcurrencySliderContainer( - page: Page -): Promise { - return page.locator('[data-testid="concurrency-slider-container"]'); -} - -/** - * Get the concurrency slider - */ -export async function getConcurrencySlider(page: Page): Promise { - return page.locator('[data-testid="concurrency-slider"]'); -} - -/** - * Get the displayed concurrency value - */ -export async function getConcurrencyValue(page: Page): Promise { - 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 { - 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); -} - -/** - * Set up a mock project with custom concurrency value - */ -export async function setupMockProjectWithConcurrency( - page: Page, - concurrency: number -): Promise { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }, concurrency); -} - -/** - * Navigate to the context view - */ -export async function navigateToContext(page: Page): Promise { - await page.goto("/"); - - // Wait for the page to load - await page.waitForLoadState("networkidle"); - - // Click on the Context nav button - const contextNav = page.locator('[data-testid="nav-context"]'); - if (await contextNav.isVisible().catch(() => false)) { - await contextNav.click(); - } - - // Wait for the context view to be visible - await waitForElement(page, "context-view", { timeout: 10000 }); -} - -/** - * Get the context file list element - */ -export async function getContextFileList(page: Page): Promise { - 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 { - const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`); - await fileButton.click(); -} - -/** - * Get the context editor element - */ -export async function getContextEditor(page: Page): Promise { - return page.locator('[data-testid="context-editor"]'); -} - -/** - * Open the add context file dialog - */ -export async function openAddContextFileDialog(page: Page): Promise { - await clickElement(page, "add-context-file"); - await waitForElement(page, "add-context-dialog"); -} - -/** - * Wait for an error toast to appear with specific text - */ -export async function waitForErrorToast( - page: Page, - titleText?: string, - options?: { timeout?: number } -): Promise { - // Sonner toasts use data-sonner-toast and data-type="error" for error toasts - const toastSelector = titleText - ? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")` - : '[data-sonner-toast][data-type="error"]'; - - const toast = page.locator(toastSelector).first(); - await toast.waitFor({ - timeout: options?.timeout ?? 5000, - state: "visible", - }); - return toast; -} - -/** - * Check if an error toast is visible - */ -export async function isErrorToastVisible( - page: Page, - titleText?: string -): Promise { - 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(); -} - -/** - * 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 { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }, - { maxConcurrency, runningTasks } - ); -} - -/** - * Get the force stop button for a specific feature - */ -export async function getForceStopButton( - page: Page, - featureId: string -): Promise { - return page.locator(`[data-testid="force-stop-${featureId}"]`); -} - -/** - * Click the force stop button for a specific feature - */ -export async function clickForceStop( - page: Page, - featureId: string -): Promise { - const button = page.locator(`[data-testid="force-stop-${featureId}"]`); - await button.click(); -} - -/** - * Check if the force stop button is visible for a feature - */ -export async function isForceStopButtonVisible( - page: Page, - featureId: string -): Promise { - const button = page.locator(`[data-testid="force-stop-${featureId}"]`); - return await button.isVisible(); -} - -/** - * Wait for a success toast to appear with specific text - */ -export async function waitForSuccessToast( - page: Page, - titleText?: string, - options?: { timeout?: number } -): Promise { - // 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; -} - -/** - * Get the delete button for an in_progress feature - */ -export async function getDeleteInProgressButton( - page: Page, - featureId: string -): Promise { - return page.locator(`[data-testid="delete-inprogress-feature-${featureId}"]`); -} - -/** - * Click the delete button for an in_progress feature - */ -export async function clickDeleteInProgressFeature( - page: Page, - featureId: string -): Promise { - const button = page.locator( - `[data-testid="delete-inprogress-feature-${featureId}"]` - ); - await button.click(); -} - -/** - * Check if the delete button is visible for an in_progress feature - */ -export async function isDeleteInProgressButtonVisible( - page: Page, - featureId: string -): Promise { - const button = page.locator( - `[data-testid="delete-inprogress-feature-${featureId}"]` - ); - return await button.isVisible(); -} - -/** - * 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 { - 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: 0, - }; - - 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 { - 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: 0, - }; - - 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 } - ); -} - -/** - * Get the category autocomplete input element - */ -export async function getCategoryAutocompleteInput( - page: Page, - testId: string = "feature-category-input" -): Promise { - return page.locator(`[data-testid="${testId}"]`); -} - -/** - * Get the category autocomplete dropdown list - */ -export async function getCategoryAutocompleteList( - page: Page -): Promise { - return page.locator('[data-testid="category-autocomplete-list"]'); -} - -/** - * Check if the category autocomplete dropdown is visible - */ -export async function isCategoryAutocompleteListVisible( - page: Page -): Promise { - 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 { - 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 { - await waitForElementHidden(page, "category-autocomplete-list", options); -} - -/** - * Click a category option in the autocomplete dropdown - */ -export async function clickCategoryOption( - page: Page, - categoryName: string -): Promise { - 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 { - const optionTestId = `category-option-${categoryName - .toLowerCase() - .replace(/\s+/g, "-")}`; - return page.locator(`[data-testid="${optionTestId}"]`); -} - -/** - * Navigate to the agent view - */ -export async function navigateToAgent(page: Page): Promise { - await page.goto("/"); - - // Wait for the page to load - await page.waitForLoadState("networkidle"); - - // Click on the Agent nav button - const agentNav = page.locator('[data-testid="nav-agent"]'); - if (await agentNav.isVisible().catch(() => false)) { - await agentNav.click(); - } - - // Wait for the agent view to be visible - await waitForElement(page, "agent-view", { timeout: 10000 }); -} - -/** - * Get the session list element - */ -export async function getSessionList(page: Page): Promise { - return page.locator('[data-testid="session-list"]'); -} - -/** - * Get the new session button - */ -export async function getNewSessionButton(page: Page): Promise { - return page.locator('[data-testid="new-session-button"]'); -} - -/** - * Click the new session button - */ -export async function clickNewSessionButton(page: Page): Promise { - const button = await getNewSessionButton(page); - await button.click(); -} - -/** - * Get a session item by its ID - */ -export async function getSessionItem( - page: Page, - sessionId: string -): Promise { - return page.locator(`[data-testid="session-item-${sessionId}"]`); -} - -/** - * Click the archive button for a session - */ -export async function clickArchiveSession( - page: Page, - sessionId: string -): Promise { - 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 { - 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 { - 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 { - const messageList = page.locator('[data-testid="message-list"]'); - return await messageList.isVisible(); -} - -/** - * Get the count up timer element for a specific feature card - */ -export async function getTimerForFeature( - page: Page, - featureId: string -): Promise { - 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 { - 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 { - const card = page.locator(`[data-testid="kanban-card-${featureId}"]`); - const timer = card.locator('[data-testid="count-up-timer"]'); - return await timer.isVisible().catch(() => false); -} - -/** - * 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 { - 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: 0, - }; - - 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); -} - -/** - * Navigate to the spec view - */ -export async function navigateToSpec(page: Page): Promise { - await page.goto("/"); - - // Wait for the page to load - await page.waitForLoadState("networkidle"); - - // Click on the Spec nav button - const specNav = page.locator('[data-testid="nav-spec"]'); - if (await specNav.isVisible().catch(() => false)) { - await specNav.click(); - } - - // Wait for the spec view to be visible - await waitForElement(page, "spec-view", { timeout: 10000 }); -} - -/** - * Get the spec editor element - */ -export async function getSpecEditor(page: Page): Promise { - return page.locator('[data-testid="spec-editor"]'); -} - -/** - * Get the spec editor content - */ -export async function getSpecEditorContent(page: Page): Promise { - const editor = await getSpecEditor(page); - return await editor.inputValue(); -} - -/** - * Set the spec editor content - */ -export async function setSpecEditorContent( - page: Page, - content: string -): Promise { - const editor = await getSpecEditor(page); - await editor.fill(content); -} - -/** - * Click the save spec button - */ -export async function clickSaveSpec(page: Page): Promise { - await clickElement(page, "save-spec"); -} - -/** - * Click the reload spec button - */ -export async function clickReloadSpec(page: Page): Promise { - await clickElement(page, "reload-spec"); -} - -/** - * Check if the spec view path display shows the correct .automaker path - */ -export async function getDisplayedSpecPath(page: Page): Promise { - const specView = page.locator('[data-testid="spec-view"]'); - const pathElement = specView.locator("p.text-muted-foreground").first(); - return await pathElement.textContent(); -} - -/** - * Get a kanban column by its ID - */ -export async function getKanbanColumn( - page: Page, - columnId: string -): Promise { - return page.locator(`[data-testid="kanban-column-${columnId}"]`); -} - -/** - * Get the width of a kanban column - */ -export async function getKanbanColumnWidth( - page: Page, - columnId: string -): Promise { - 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 { - 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"; -} - -/** - * Set up a mock project with a specific current view for route persistence testing - */ -export async function setupMockProjectWithView( - page: Page, - view: string -): Promise { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }, view); -} - -/** - * Navigate to a specific view using the sidebar navigation - */ -export async function navigateToView( - page: Page, - viewId: string -): Promise { - 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 { - // 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; -} - -/** - * Check if the drag handle is visible for a specific feature card - */ -export async function isDragHandleVisibleForFeature( - page: Page, - featureId: string -): Promise { - 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 { - return page.locator(`[data-testid="drag-handle-${featureId}"]`); -} - -/** - * Navigate to the welcome view (clear project selection) - */ -export async function navigateToWelcome(page: Page): Promise { - await page.goto("/"); - await page.waitForLoadState("networkidle"); - await waitForElement(page, "welcome-view", { timeout: 10000 }); -} - -/** - * Set up an empty localStorage (no projects) to show welcome screen - */ -export async function setupEmptyLocalStorage(page: Page): Promise { - 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)); - }); -} - -/** - * Set up mock projects in localStorage but with no current project (for recent projects list) - */ -export async function setupMockProjectsWithoutCurrent( - page: Page -): Promise { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }); -} - -/** - * Check if the project initialization dialog is visible - */ -export async function isProjectInitDialogVisible(page: Page): Promise { - 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 { - return await waitForElement(page, "project-init-dialog", options); -} - -/** - * Close the project initialization dialog - */ -export async function closeProjectInitDialog(page: Page): Promise { - const closeButton = page.locator('[data-testid="close-init-dialog"]'); - await closeButton.click(); -} - -/** - * Check if the project opening overlay is visible - */ -export async function isProjectOpeningOverlayVisible( - page: Page -): Promise { - const overlay = page.locator('[data-testid="project-opening-overlay"]'); - return await overlay.isVisible(); -} - -/** - * Wait for the project opening overlay to disappear - */ -export async function waitForProjectOpeningOverlayHidden( - page: Page, - options?: { timeout?: number } -): Promise { - await waitForElementHidden(page, "project-opening-overlay", options); -} - -/** - * Click on a recent project in the welcome view - */ -export async function clickRecentProject( - page: Page, - projectId: string -): Promise { - await clickElement(page, `recent-project-${projectId}`); -} - -/** - * Click the open project card in the welcome view - */ -export async function clickOpenProjectCard(page: Page): Promise { - await clickElement(page, "open-project-card"); -} - -/** - * Check if a navigation item exists in the sidebar - */ -export async function isNavItemVisible( - page: Page, - navId: string -): Promise { - const navItem = page.locator(`[data-testid="nav-${navId}"]`); - return await navItem.isVisible().catch(() => false); -} - -/** - * Get all visible navigation items in the sidebar - */ -export async function getVisibleNavItems(page: Page): Promise { - const navItems = page.locator('[data-testid^="nav-"]'); - const count = await navItems.count(); - const items: string[] = []; - - for (let i = 0; i < count; i++) { - const testId = await navItems.nth(i).getAttribute("data-testid"); - if (testId) { - items.push(testId.replace("nav-", "")); - } - } - - return items; -} - -/** - * Press a keyboard shortcut key - */ -export async function pressShortcut(page: Page, key: string): Promise { - await page.keyboard.press(key); -} - -/** - * Count the number of session items in the session list - */ -export async function countSessionItems(page: Page): Promise { - 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 { - // 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", - }); -} - -/** - * Check if a shortcut key indicator is visible for a navigation item - */ -export async function isShortcutIndicatorVisible( - page: Page, - navId: string -): Promise { - const shortcut = page.locator(`[data-testid="shortcut-${navId}"]`); - return await shortcut.isVisible().catch(() => false); -} - -/** - * Get the shortcut key text for a navigation item - */ -export async function getShortcutKeyText( - page: Page, - navId: string -): Promise { - const shortcut = page.locator(`[data-testid="shortcut-${navId}"]`); - return await shortcut.textContent(); -} - -/** - * Focus on an input element to test that shortcuts don't fire when typing - */ -export async function focusOnInput(page: Page, testId: string): Promise { - const input = page.locator(`[data-testid="${testId}"]`); - await input.focus(); -} - -/** - * Check if the add feature dialog is visible - */ -export async function isAddFeatureDialogVisible(page: Page): Promise { - 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 { - const dialog = page.locator('[data-testid="add-context-dialog"]'); - return await dialog.isVisible().catch(() => false); -} - -/** - * Close any open dialog by pressing Escape - */ -export async function closeDialogWithEscape(page: Page): Promise { - await page.keyboard.press("Escape"); - await page.waitForTimeout(100); // Give dialog time to close -} - -/** - * Wait for a toast notification with specific text to appear - */ -export async function waitForToast( - page: Page, - text: string, - options?: { timeout?: number } -): Promise { - const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first(); - await toast.waitFor({ - timeout: options?.timeout ?? 5000, - state: "visible", - }); - return toast; -} - -/** - * Check if project analysis is in progress (analyzing spinner is visible) - */ -export async function isProjectAnalyzingVisible(page: Page): Promise { - const analyzingText = page.locator('p:has-text("AI agent is analyzing")'); - return await analyzingText.isVisible().catch(() => false); -} - -/** - * Wait for project analysis to complete (no longer analyzing) - */ -export async function waitForProjectAnalysisComplete( - page: Page, - options?: { timeout?: number } -): Promise { - // Wait for the analyzing text to disappear - const analyzingText = page.locator('p:has-text("AI agent is analyzing")'); - await analyzingText - .waitFor({ - timeout: options?.timeout ?? 10000, - state: "hidden", - }) - .catch(() => { - // It may never have been visible, that's ok - }); -} - -/** - * Get the delete confirmation dialog - */ -export async function getDeleteConfirmationDialog( - page: Page -): Promise { - return page.locator('[data-testid="delete-confirmation-dialog"]'); -} - -/** - * Check if the delete confirmation dialog is visible - */ -export async function isDeleteConfirmationDialogVisible( - page: Page -): Promise { - 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 { - 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 { - await waitForElementHidden(page, "delete-confirmation-dialog", options); -} - -/** - * Click the confirm delete button in the delete confirmation dialog - */ -export async function clickConfirmDeleteButton(page: Page): Promise { - await clickElement(page, "confirm-delete-button"); -} - -/** - * Click the cancel delete button in the delete confirmation dialog - */ -export async function clickCancelDeleteButton(page: Page): Promise { - await clickElement(page, "cancel-delete-button"); -} - -/** - * Click the delete button for a backlog feature card - */ -export async function clickDeleteFeatureButton( - page: Page, - featureId: string -): Promise { - const button = page.locator(`[data-testid="delete-feature-${featureId}"]`); - await button.click(); -} - -/** - * Check if the delete button is visible for a backlog feature - */ -export async function isDeleteFeatureButtonVisible( - page: Page, - featureId: string -): Promise { - const button = page.locator(`[data-testid="delete-feature-${featureId}"]`); - return await button.isVisible().catch(() => false); -} - -/** - * Check if the edit feature dialog is visible - */ -export async function isEditFeatureDialogVisible(page: Page): Promise { - 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 { - return await waitForElement(page, "edit-feature-dialog", options); -} - -/** - * Get the edit feature description input/textarea element - */ -export async function getEditFeatureDescriptionInput( - page: Page -): Promise { - 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 { - 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 { - await clickElement(page, `edit-feature-${featureId}`); - await waitForEditFeatureDialog(page); -} - -/** - * Fill the edit feature description field - */ -export async function fillEditFeatureDescription( - page: Page, - value: string -): Promise { - const input = await getEditFeatureDescriptionInput(page); - await input.fill(value); -} - -/** - * Click the confirm edit feature button - */ -export async function confirmEditFeature(page: Page): Promise { - await clickElement(page, "confirm-edit-feature"); -} - -/** - * Get the skip tests checkbox element in the add feature dialog - */ -export async function getSkipTestsCheckbox(page: Page): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const button = page.locator(`[data-testid="move-back-${featureId}"]`); - return await button.isVisible().catch(() => false); -} - -/** - * 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 { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }, options); -} - -/** - * Press a number key (0-9) on the keyboard - */ -export async function pressNumberKey(page: Page, num: number): Promise { - await page.keyboard.press(num.toString()); -} - -/** - * Get the modal title/description text to verify which feature's output is being shown - */ -export async function getAgentOutputModalDescription( - page: Page -): Promise { - 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 { - const modalDescription = page.locator( - '[data-testid="agent-output-modal"] [data-slot="dialog-description"]' - ); - return await modalDescription.textContent().catch(() => null); -} - -/** - * Check if the project picker dropdown is open - */ -export async function isProjectPickerDropdownOpen( - page: Page -): Promise { - const dropdown = page.locator('[data-testid="project-picker-dropdown"]'); - return await dropdown.isVisible().catch(() => false); -} - -/** - * Wait for the project picker dropdown to be visible - */ -export async function waitForProjectPickerDropdown( - page: Page, - options?: { timeout?: number } -): Promise { - return await waitForElement(page, "project-picker-dropdown", options); -} - -/** - * Wait for the project picker dropdown to be hidden - */ -export async function waitForProjectPickerDropdownHidden( - page: Page, - options?: { timeout?: number } -): Promise { - await waitForElementHidden(page, "project-picker-dropdown", options); -} - -/** - * Get a project hotkey indicator element by number (1-5) - */ -export async function getProjectHotkey( - page: Page, - num: number -): Promise { - return page.locator(`[data-testid="project-hotkey-${num}"]`); -} - -/** - * Check if a project hotkey indicator is visible - */ -export async function isProjectHotkeyVisible( - page: Page, - num: number -): Promise { - const hotkey = page.locator(`[data-testid="project-hotkey-${num}"]`); - return await hotkey.isVisible().catch(() => false); -} - -/** - * Get the project picker shortcut indicator (P key) - */ -export async function getProjectPickerShortcut(page: Page): Promise { - return page.locator('[data-testid="project-picker-shortcut"]'); -} - -/** - * Set up a mock state with multiple projects - */ -export async function setupMockMultipleProjects( - page: Page, - projectCount: number = 3 -): Promise { - 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: 0, - }; - - localStorage.setItem("automaker-storage", JSON.stringify(mockState)); - }, projectCount); -} - -/** - * Get the description image dropzone element - */ -export async function getDescriptionImageDropzone( - page: Page -): Promise { - return page.locator('[data-testid="feature-description-input"]'); -} - -/** - * Get the description image hidden input element - */ -export async function getDescriptionImageInput(page: Page): Promise { - return page.locator('[data-testid="description-image-input"]'); -} - -/** - * Check if the description image previews section is visible - */ -export async function isDescriptionImagePreviewsVisible( - page: Page -): Promise { - const previews = page.locator('[data-testid="description-image-previews"]'); - return await previews.isVisible().catch(() => false); -} - -/** - * Get the number of description image previews - */ -export async function getDescriptionImagePreviewCount( - page: Page -): Promise { - const previews = page.locator('[data-testid^="description-image-preview-"]'); - return await previews.count(); -} - -/** - * Upload an image to the description dropzone via the file input - */ -export async function uploadDescriptionImage( - page: Page, - imagePath: string -): Promise { - const input = page.locator('[data-testid="description-image-input"]'); - await input.setInputFiles(imagePath); -} - -/** - * Create a test PNG image as a data URL - */ -export function createTestImageDataUrl(): string { - // A tiny 1x1 transparent PNG as base64 - return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; -} - -/** - * Wait for description image preview to appear - */ -export async function waitForDescriptionImagePreview( - page: Page, - options?: { timeout?: number } -): Promise { - const preview = page - .locator('[data-testid^="description-image-preview-"]') - .first(); - await preview.waitFor({ - timeout: options?.timeout ?? 5000, - state: "visible", - }); - return preview; -} - -/** - * Check if the drop overlay is visible on the description area - */ -export async function isDropOverlayVisible(page: Page): Promise { - const overlay = page.locator('[data-testid="drop-overlay"]'); - return await overlay.isVisible().catch(() => false); -} - -/** - * Navigate to the settings view - */ -export async function navigateToSettings(page: Page): Promise { - await page.goto("/"); - - // Wait for the page to load - await page.waitForLoadState("networkidle"); - - // Click on the Settings button in the sidebar - const settingsButton = page.locator('[data-testid="settings-button"]'); - if (await settingsButton.isVisible().catch(() => false)) { - await settingsButton.click(); - } - - // Wait for the settings view to be visible - await waitForElement(page, "settings-view", { timeout: 10000 }); -} - -/** - * Get the settings view scrollable content area - */ -export async function getSettingsContentArea(page: Page): Promise { - return page.locator('[data-testid="settings-view"] .overflow-y-auto'); -} - -/** - * Check if an element is scrollable (has scrollable content) - */ -export async function isElementScrollable(locator: Locator): Promise { - 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 { - 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 { - 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 - ); -} - -// ============ Log Viewer Utilities ============ - -/** - * Get the log viewer header element (contains type counts and expand/collapse buttons) - */ -export async function getLogViewerHeader(page: Page): Promise { - return page.locator('[data-testid="log-viewer-header"]'); -} - -/** - * Check if the log viewer header is visible - */ -export async function isLogViewerHeaderVisible(page: Page): Promise { - 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 { - return page.locator('[data-testid="log-entries-container"]'); -} - -/** - * Get a log entry by its type - */ -export async function getLogEntryByType( - page: Page, - type: string -): Promise { - 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 { - return page.locator(`[data-testid="log-entry-${type}"]`); -} - -/** - * Count log entries of a specific type - */ -export async function countLogEntriesByType( - page: Page, - type: string -): Promise { - 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 { - 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 { - 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 { - await clickElement(page, "log-expand-all"); -} - -/** - * Click the collapse all button in the log viewer - */ -export async function clickLogCollapseAll(page: Page): Promise { - await clickElement(page, "log-collapse-all"); -} - -/** - * Get a log entry badge element - */ -export async function getLogEntryBadge(page: Page): Promise { - return page.locator('[data-testid="log-entry-badge"]').first(); -} - -/** - * Check if any log entry badge is visible - */ -export async function isLogEntryBadgeVisible(page: Page): Promise { - 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 { - return page.locator(`[data-testid="view-mode-${mode}"]`); -} - -/** - * Click a view mode toggle button - */ -export async function clickViewModeButton( - page: Page, - mode: "parsed" | "raw" -): Promise { - 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 { - const button = page.locator(`[data-testid="view-mode-${mode}"]`); - const classes = await button.getAttribute("class"); - return classes?.includes("text-purple-300") ?? false; -} - -/** - * Set up a mock project with agent output content in the context file - */ -export async function setupMockProjectWithAgentOutput( - page: Page, - featureId: string, - outputContent: string -): Promise { - 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: 0, - }; - - 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 } - ); -} - -// ============ Waiting Approval Feature Utilities ============ - -/** - * Get the follow-up button for a waiting_approval feature - */ -export async function getFollowUpButton( - page: Page, - featureId: string -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - const button = page.locator(`[data-testid="commit-${featureId}"]`); - return await button.isVisible().catch(() => false); -} - -/** - * Check if the follow-up dialog is visible - */ -export async function isFollowUpDialogVisible(page: Page): Promise { - 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 { - 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 { - 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 { - await clickElement(page, "confirm-follow-up"); -} - -/** - * Get the waiting_approval kanban column - */ -export async function getWaitingApprovalColumn(page: Page): Promise { - return page.locator('[data-testid="kanban-column-waiting_approval"]'); -} - -/** - * Check if the waiting_approval column is visible - */ -export async function isWaitingApprovalColumnVisible( - page: Page -): Promise { - const column = page.locator('[data-testid="kanban-column-waiting_approval"]'); - return await column.isVisible().catch(() => false); -} - -/** - * Get the agent output modal description element - */ -export async function getAgentOutputModalDescriptionElement( - page: Page -): Promise { - return page.locator('[data-testid="agent-output-description"]'); -} - -/** - * Check if the agent output modal description is scrollable - */ -export async function isAgentOutputDescriptionScrollable( - page: Page -): Promise { - 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, - }; - }); -} - -/** - * 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 { - 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: 0, - }; - - 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); -} - -// ============================================================================ -// Setup View Utilities -// ============================================================================ - -/** - * Set up the app store to show setup view (simulate first run) - */ -export async function setupFirstRun(page: Page): Promise { - 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: 0, - }; - - 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: 0, - }; - - 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 { - await page.addInitScript(() => { - // 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 setup view directly - */ -export async function navigateToSetup(page: Page): Promise { - await setupFirstRun(page); - await page.goto("/"); - await page.waitForLoadState("networkidle"); - await waitForElement(page, "setup-view", { timeout: 10000 }); -} - -/** - * Wait for setup view to be visible - */ -export async function waitForSetupView(page: Page): Promise { - return waitForElement(page, "setup-view", { timeout: 10000 }); -} - -/** - * Click "Get Started" button on setup welcome step - */ -export async function clickSetupGetStarted(page: Page): Promise { - const button = await getByTestId(page, "setup-start-button"); - await button.click(); -} - -/** - * Click continue on Claude setup step - */ -export async function clickClaudeContinue(page: Page): Promise { - const button = await getByTestId(page, "claude-next-button"); - await button.click(); -} - -/** - * Click finish on setup complete step - */ -export async function clickSetupFinish(page: Page): Promise { - 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 { - // 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 { - // 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(); -} diff --git a/apps/app/tests/utils/components/autocomplete.ts b/apps/app/tests/utils/components/autocomplete.ts new file mode 100644 index 00000000..4850cf24 --- /dev/null +++ b/apps/app/tests/utils/components/autocomplete.ts @@ -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 { + 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 { + 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 { + await waitForElementHidden(page, "category-autocomplete-list", options); +} + +/** + * Click a category option in the autocomplete dropdown + */ +export async function clickCategoryOption( + page: Page, + categoryName: string +): Promise { + 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 { + const optionTestId = `category-option-${categoryName + .toLowerCase() + .replace(/\s+/g, "-")}`; + return page.locator(`[data-testid="${optionTestId}"]`); +} diff --git a/apps/app/tests/utils/components/dialogs.ts b/apps/app/tests/utils/components/dialogs.ts new file mode 100644 index 00000000..87060798 --- /dev/null +++ b/apps/app/tests/utils/components/dialogs.ts @@ -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 { + 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 { + 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 { + 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 { + return await waitForElement(page, "edit-feature-dialog", options); +} + +/** + * Get the edit feature description input/textarea element + */ +export async function getEditFeatureDescriptionInput( + page: Page +): Promise { + 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 { + 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 { + await clickElement(page, `edit-feature-${featureId}`); + await waitForEditFeatureDialog(page); +} + +/** + * Fill the edit feature description field + */ +export async function fillEditFeatureDescription( + page: Page, + value: string +): Promise { + const input = await getEditFeatureDescriptionInput(page); + await input.fill(value); +} + +/** + * Click the confirm edit feature button + */ +export async function confirmEditFeature(page: Page): Promise { + await clickElement(page, "confirm-edit-feature"); +} + +/** + * Get the delete confirmation dialog + */ +export async function getDeleteConfirmationDialog( + page: Page +): Promise { + return page.locator('[data-testid="delete-confirmation-dialog"]'); +} + +/** + * Check if the delete confirmation dialog is visible + */ +export async function isDeleteConfirmationDialogVisible( + page: Page +): Promise { + 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 { + 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 { + await waitForElementHidden(page, "delete-confirmation-dialog", options); +} + +/** + * Click the confirm delete button in the delete confirmation dialog + */ +export async function clickConfirmDeleteButton(page: Page): Promise { + await clickElement(page, "confirm-delete-button"); +} + +/** + * Click the cancel delete button in the delete confirmation dialog + */ +export async function clickCancelDeleteButton(page: Page): Promise { + await clickElement(page, "cancel-delete-button"); +} + +/** + * Check if the follow-up dialog is visible + */ +export async function isFollowUpDialogVisible(page: Page): Promise { + 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 { + 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 { + 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 { + await clickElement(page, "confirm-follow-up"); +} + +/** + * Check if the project initialization dialog is visible + */ +export async function isProjectInitDialogVisible(page: Page): Promise { + 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 { + return await waitForElement(page, "project-init-dialog", options); +} + +/** + * Close the project initialization dialog + */ +export async function closeProjectInitDialog(page: Page): Promise { + const closeButton = page.locator('[data-testid="close-init-dialog"]'); + await closeButton.click(); +} diff --git a/apps/app/tests/utils/components/modals.ts b/apps/app/tests/utils/components/modals.ts new file mode 100644 index 00000000..5195871c --- /dev/null +++ b/apps/app/tests/utils/components/modals.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return page.locator('[data-testid="agent-output-description"]'); +} + +/** + * Check if the agent output modal description is scrollable + */ +export async function isAgentOutputDescriptionScrollable( + page: Page +): Promise { + 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, + }; + }); +} diff --git a/apps/app/tests/utils/components/toasts.ts b/apps/app/tests/utils/components/toasts.ts new file mode 100644 index 00000000..a03e5938 --- /dev/null +++ b/apps/app/tests/utils/components/toasts.ts @@ -0,0 +1,75 @@ +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 { + 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 { + // Sonner toasts use data-sonner-toast and data-type="error" for error toasts + const toastSelector = titleText + ? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")` + : '[data-sonner-toast][data-type="error"]'; + + const toast = page.locator(toastSelector).first(); + await toast.waitFor({ + timeout: options?.timeout ?? 5000, + state: "visible", + }); + return toast; +} + +/** + * Check if an error toast is visible + */ +export async function isErrorToastVisible( + page: Page, + titleText?: string +): Promise { + 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 { + // 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; +} diff --git a/apps/app/tests/utils/core/elements.ts b/apps/app/tests/utils/core/elements.ts new file mode 100644 index 00000000..8a279c4c --- /dev/null +++ b/apps/app/tests/utils/core/elements.ts @@ -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 { + return page.locator(`[data-testid="${testId}"]`); +} + +/** + * Get a button by its text content + */ +export async function getButtonByText( + page: Page, + text: string +): Promise { + 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 { + return page.locator(`[data-testid="${testId}"]`); +} + +/** + * Get the category autocomplete dropdown list + */ +export async function getCategoryAutocompleteList( + page: Page +): Promise { + return page.locator('[data-testid="category-autocomplete-list"]'); +} diff --git a/apps/app/tests/utils/core/interactions.ts b/apps/app/tests/utils/core/interactions.ts new file mode 100644 index 00000000..b4ba0d02 --- /dev/null +++ b/apps/app/tests/utils/core/interactions.ts @@ -0,0 +1,63 @@ +import { Page } from "@playwright/test"; +import { getByTestId, getButtonByText } from "./elements"; + +/** + * Click an element by its data-testid attribute + */ +export async function clickElement(page: Page, testId: string): Promise { + 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 { + 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 { + const input = await getByTestId(page, testId); + await input.fill(value); +} + +/** + * Press a keyboard shortcut key + */ +export async function pressShortcut(page: Page, key: string): Promise { + await page.keyboard.press(key); +} + +/** + * Press a number key (0-9) on the keyboard + */ +export async function pressNumberKey(page: Page, num: number): Promise { + 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 { + const input = page.locator(`[data-testid="${testId}"]`); + await input.focus(); +} + +/** + * Close any open dialog by pressing Escape + */ +export async function closeDialogWithEscape(page: Page): Promise { + await page.keyboard.press("Escape"); + await page.waitForTimeout(100); // Give dialog time to close +} diff --git a/apps/app/tests/utils/core/waiting.ts b/apps/app/tests/utils/core/waiting.ts new file mode 100644 index 00000000..c3bdfa72 --- /dev/null +++ b/apps/app/tests/utils/core/waiting.ts @@ -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 { + 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 { + 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 { + const element = page.locator(`[data-testid="${testId}"]`); + await element.waitFor({ + timeout: options?.timeout ?? 5000, + state: "hidden", + }); +} diff --git a/apps/app/tests/utils/features/kanban.ts b/apps/app/tests/utils/features/kanban.ts new file mode 100644 index 00000000..6eb1bb77 --- /dev/null +++ b/apps/app/tests/utils/features/kanban.ts @@ -0,0 +1,34 @@ +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 + */ +export async function dragAndDropWithDndKit( + page: Page, + sourceLocator: Locator, + targetLocator: Locator +): Promise { + 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; + + // Perform the drag and drop with pointer events + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.waitForTimeout(150); // Give dnd-kit time to recognize the drag + await page.mouse.move(endX, endY, { steps: 15 }); + await page.waitForTimeout(100); // Allow time for drop detection + await page.mouse.up(); +} diff --git a/apps/app/tests/utils/features/skip-tests.ts b/apps/app/tests/utils/features/skip-tests.ts new file mode 100644 index 00000000..6fba9314 --- /dev/null +++ b/apps/app/tests/utils/features/skip-tests.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const button = page.locator(`[data-testid="move-back-${featureId}"]`); + return await button.isVisible().catch(() => false); +} diff --git a/apps/app/tests/utils/features/timers.ts b/apps/app/tests/utils/features/timers.ts new file mode 100644 index 00000000..c7c0ed17 --- /dev/null +++ b/apps/app/tests/utils/features/timers.ts @@ -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 { + 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 { + 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 { + const card = page.locator(`[data-testid="kanban-card-${featureId}"]`); + const timer = card.locator('[data-testid="count-up-timer"]'); + return await timer.isVisible().catch(() => false); +} diff --git a/apps/app/tests/utils/features/waiting-approval.ts b/apps/app/tests/utils/features/waiting-approval.ts new file mode 100644 index 00000000..3a443f8a --- /dev/null +++ b/apps/app/tests/utils/features/waiting-approval.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return page.locator('[data-testid="kanban-column-waiting_approval"]'); +} + +/** + * Check if the waiting_approval column is visible + */ +export async function isWaitingApprovalColumnVisible( + page: Page +): Promise { + const column = page.locator('[data-testid="kanban-column-waiting_approval"]'); + return await column.isVisible().catch(() => false); +} diff --git a/apps/app/tests/utils/files/drag-drop.ts b/apps/app/tests/utils/files/drag-drop.ts new file mode 100644 index 00000000..1a0c0d4a --- /dev/null +++ b/apps/app/tests/utils/files/drag-drop.ts @@ -0,0 +1,38 @@ +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 { + 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 } + ); +} diff --git a/apps/app/tests/utils/helpers/concurrency.ts b/apps/app/tests/utils/helpers/concurrency.ts new file mode 100644 index 00000000..9d42bd9b --- /dev/null +++ b/apps/app/tests/utils/helpers/concurrency.ts @@ -0,0 +1,50 @@ +import { Page, Locator } from "@playwright/test"; + +/** + * Get the concurrency slider container + */ +export async function getConcurrencySliderContainer( + page: Page +): Promise { + return page.locator('[data-testid="concurrency-slider-container"]'); +} + +/** + * Get the concurrency slider + */ +export async function getConcurrencySlider(page: Page): Promise { + return page.locator('[data-testid="concurrency-slider"]'); +} + +/** + * Get the displayed concurrency value + */ +export async function getConcurrencyValue(page: Page): Promise { + 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 { + 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); +} diff --git a/apps/app/tests/utils/helpers/log-viewer.ts b/apps/app/tests/utils/helpers/log-viewer.ts new file mode 100644 index 00000000..7e112093 --- /dev/null +++ b/apps/app/tests/utils/helpers/log-viewer.ts @@ -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 { + return page.locator('[data-testid="log-viewer-header"]'); +} + +/** + * Check if the log viewer header is visible + */ +export async function isLogViewerHeaderVisible(page: Page): Promise { + 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 { + return page.locator('[data-testid="log-entries-container"]'); +} + +/** + * Get a log entry by its type + */ +export async function getLogEntryByType( + page: Page, + type: string +): Promise { + 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 { + return page.locator(`[data-testid="log-entry-${type}"]`); +} + +/** + * Count log entries of a specific type + */ +export async function countLogEntriesByType( + page: Page, + type: string +): Promise { + 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 { + 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 { + 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 { + await clickElement(page, "log-expand-all"); +} + +/** + * Click the collapse all button in the log viewer + */ +export async function clickLogCollapseAll(page: Page): Promise { + await clickElement(page, "log-collapse-all"); +} + +/** + * Get a log entry badge element + */ +export async function getLogEntryBadge(page: Page): Promise { + return page.locator('[data-testid="log-entry-badge"]').first(); +} + +/** + * Check if any log entry badge is visible + */ +export async function isLogEntryBadgeVisible(page: Page): Promise { + 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 { + return page.locator(`[data-testid="view-mode-${mode}"]`); +} + +/** + * Click a view mode toggle button + */ +export async function clickViewModeButton( + page: Page, + mode: "parsed" | "raw" +): Promise { + 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 { + const button = page.locator(`[data-testid="view-mode-${mode}"]`); + const classes = await button.getAttribute("class"); + return classes?.includes("text-purple-300") ?? false; +} diff --git a/apps/app/tests/utils/helpers/scroll.ts b/apps/app/tests/utils/helpers/scroll.ts new file mode 100644 index 00000000..58057e78 --- /dev/null +++ b/apps/app/tests/utils/helpers/scroll.ts @@ -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 { + 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 { + 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 { + 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 + ); +} diff --git a/apps/app/tests/utils/index.ts b/apps/app/tests/utils/index.ts new file mode 100644 index 00000000..578e4ad8 --- /dev/null +++ b/apps/app/tests/utils/index.ts @@ -0,0 +1,41 @@ +// Re-export all utilities from their respective modules + +// Core utilities +export * from "./core/elements"; +export * from "./core/interactions"; +export * from "./core/waiting"; + +// 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"; + +// 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"; diff --git a/apps/app/tests/utils/navigation/views.ts b/apps/app/tests/utils/navigation/views.ts new file mode 100644 index 00000000..3bb35fc4 --- /dev/null +++ b/apps/app/tests/utils/navigation/views.ts @@ -0,0 +1,159 @@ +import { Page } from "@playwright/test"; +import { clickElement } from "../core/interactions"; +import { waitForElement } from "../core/waiting"; + +/** + * Navigate to the board/kanban view + */ +export async function navigateToBoard(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Check if we're on the board view already + const boardView = page.locator('[data-testid="board-view"]'); + const isOnBoard = await boardView.isVisible().catch(() => false); + + if (!isOnBoard) { + // Try to click on a recent project first (from welcome screen) + const recentProject = page.locator('p:has-text("Test Project")').first(); + if (await recentProject.isVisible().catch(() => false)) { + await recentProject.click(); + await page.waitForTimeout(200); + } + + // Then click on Kanban Board nav button to ensure we're on the board + const kanbanNav = page.locator('[data-testid="nav-board"]'); + if (await kanbanNav.isVisible().catch(() => false)) { + await kanbanNav.click(); + } + } + + // Wait for the board view to be visible + await waitForElement(page, "board-view", { timeout: 10000 }); +} + +/** + * Navigate to the context view + */ +export async function navigateToContext(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Click on the Context nav button + const contextNav = page.locator('[data-testid="nav-context"]'); + if (await contextNav.isVisible().catch(() => false)) { + await contextNav.click(); + } + + // Wait for the context view to be visible + await waitForElement(page, "context-view", { timeout: 10000 }); +} + +/** + * Navigate to the spec view + */ +export async function navigateToSpec(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Click on the Spec nav button + const specNav = page.locator('[data-testid="nav-spec"]'); + if (await specNav.isVisible().catch(() => false)) { + await specNav.click(); + } + + // Wait for the spec view to be visible + await waitForElement(page, "spec-view", { timeout: 10000 }); +} + +/** + * Navigate to the agent view + */ +export async function navigateToAgent(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Click on the Agent nav button + const agentNav = page.locator('[data-testid="nav-agent"]'); + if (await agentNav.isVisible().catch(() => false)) { + await agentNav.click(); + } + + // Wait for the agent view to be visible + await waitForElement(page, "agent-view", { timeout: 10000 }); +} + +/** + * Navigate to the settings view + */ +export async function navigateToSettings(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Click on the Settings button in the sidebar + const settingsButton = page.locator('[data-testid="settings-button"]'); + if (await settingsButton.isVisible().catch(() => false)) { + await settingsButton.click(); + } + + // 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 { + // 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 { + 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 { + 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 { + // 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; +} diff --git a/apps/app/tests/utils/project/fixtures.ts b/apps/app/tests/utils/project/fixtures.ts new file mode 100644 index 00000000..9c71bab1 --- /dev/null +++ b/apps/app/tests/utils/project/fixtures.ts @@ -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/app and from root + */ +export function getWorkspaceRoot(): string { + const cwd = process.cwd(); + if (cwd.includes("apps/app")) { + 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 = ` + Test Project A + A test fixture project for Playwright testing + + TypeScript + React + + +`; + +/** + * 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 { + 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: 0, + }; + + 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: 0, + }; + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + }, projectPath); +} + +/** + * Get the fixture path + */ +export function getFixturePath(): string { + return FIXTURE_PATH; +} diff --git a/apps/app/tests/utils/project/setup.ts b/apps/app/tests/utils/project/setup.ts new file mode 100644 index 00000000..2a894f43 --- /dev/null +++ b/apps/app/tests/utils/project/setup.ts @@ -0,0 +1,635 @@ +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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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)); + }); +} + +/** + * Set up mock projects in localStorage but with no current project (for recent projects list) + */ +export async function setupMockProjectsWithoutCurrent( + page: Page +): Promise { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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 { + 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: 0, + }; + + 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: 0, + }; + + 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 { + await page.addInitScript(() => { + // Mark setup as complete + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + currentStep: "complete", + skipClaudeSetup: false, + }, + version: 0, + }; + + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + }); +} diff --git a/apps/app/tests/utils/views/agent.ts b/apps/app/tests/utils/views/agent.ts new file mode 100644 index 00000000..a4164921 --- /dev/null +++ b/apps/app/tests/utils/views/agent.ts @@ -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 { + return page.locator('[data-testid="session-list"]'); +} + +/** + * Get the new session button + */ +export async function getNewSessionButton(page: Page): Promise { + return page.locator('[data-testid="new-session-button"]'); +} + +/** + * Click the new session button + */ +export async function clickNewSessionButton(page: Page): Promise { + const button = await getNewSessionButton(page); + await button.click(); +} + +/** + * Get a session item by its ID + */ +export async function getSessionItem( + page: Page, + sessionId: string +): Promise { + return page.locator(`[data-testid="session-item-${sessionId}"]`); +} + +/** + * Click the archive button for a session + */ +export async function clickArchiveSession( + page: Page, + sessionId: string +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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", + }); +} diff --git a/apps/app/tests/utils/views/board.ts b/apps/app/tests/utils/views/board.ts new file mode 100644 index 00000000..a941aa3a --- /dev/null +++ b/apps/app/tests/utils/views/board.ts @@ -0,0 +1,112 @@ +import { Page, Locator } from "@playwright/test"; + +/** + * Get a kanban card by feature ID + */ +export async function getKanbanCard( + page: Page, + featureId: string +): Promise { + return page.locator(`[data-testid="kanban-card-${featureId}"]`); +} + +/** + * Get a kanban column by its ID + */ +export async function getKanbanColumn( + page: Page, + columnId: string +): Promise { + return page.locator(`[data-testid="kanban-column-${columnId}"]`); +} + +/** + * Get the width of a kanban column + */ +export async function getKanbanColumnWidth( + page: Page, + columnId: string +): Promise { + 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 { + 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 { + 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 { + // 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 { + 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 { + return page.locator(`[data-testid="drag-handle-${featureId}"]`); +} diff --git a/apps/app/tests/utils/views/context.ts b/apps/app/tests/utils/views/context.ts new file mode 100644 index 00000000..4504cde8 --- /dev/null +++ b/apps/app/tests/utils/views/context.ts @@ -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 { + 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 { + const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`); + await fileButton.click(); +} + +/** + * Get the context editor element + */ +export async function getContextEditor(page: Page): Promise { + return page.locator('[data-testid="context-editor"]'); +} + +/** + * Get the context editor content + */ +export async function getContextEditorContent(page: Page): Promise { + 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 { + const editor = await getByTestId(page, "context-editor"); + await editor.fill(content); +} + +/** + * Open the add context file dialog + */ +export async function openAddContextFileDialog(page: Page): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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, + }); + } +} diff --git a/apps/app/tests/utils/views/settings.ts b/apps/app/tests/utils/views/settings.ts new file mode 100644 index 00000000..a5f1ed1f --- /dev/null +++ b/apps/app/tests/utils/views/settings.ts @@ -0,0 +1,8 @@ +import { Page, Locator } from "@playwright/test"; + +/** + * Get the settings view scrollable content area + */ +export async function getSettingsContentArea(page: Page): Promise { + return page.locator('[data-testid="settings-view"] .overflow-y-auto'); +} diff --git a/apps/app/tests/utils/views/setup.ts b/apps/app/tests/utils/views/setup.ts new file mode 100644 index 00000000..b7773d70 --- /dev/null +++ b/apps/app/tests/utils/views/setup.ts @@ -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 { + return waitForElement(page, "setup-view", { timeout: 10000 }); +} + +/** + * Click "Get Started" button on setup welcome step + */ +export async function clickSetupGetStarted(page: Page): Promise { + const button = await getByTestId(page, "setup-start-button"); + await button.click(); +} + +/** + * Click continue on Claude setup step + */ +export async function clickClaudeContinue(page: Page): Promise { + const button = await getByTestId(page, "claude-next-button"); + await button.click(); +} + +/** + * Click finish on setup complete step + */ +export async function clickSetupFinish(page: Page): Promise { + 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 { + // 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 { + // 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(); +} diff --git a/apps/app/tests/utils/views/spec-editor.ts b/apps/app/tests/utils/views/spec-editor.ts new file mode 100644 index 00000000..e8f86dd1 --- /dev/null +++ b/apps/app/tests/utils/views/spec-editor.ts @@ -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 { + return page.locator('[data-testid="spec-editor"]'); +} + +/** + * Get the spec editor content + */ +export async function getSpecEditorContent(page: Page): Promise { + const editor = await getSpecEditor(page); + return await editor.inputValue(); +} + +/** + * Set the spec editor content + */ +export async function setSpecEditorContent( + page: Page, + content: string +): Promise { + const editor = await getSpecEditor(page); + await editor.fill(content); +} + +/** + * Click the save spec button + */ +export async function clickSaveSpec(page: Page): Promise { + await clickElement(page, "save-spec"); +} + +/** + * Click the reload spec button + */ +export async function clickReloadSpec(page: Page): Promise { + await clickElement(page, "reload-spec"); +} + +/** + * Check if the spec view path display shows the correct .automaker path + */ +export async function getDisplayedSpecPath(page: Page): Promise { + 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 { + await navigateToSpec(page); +} + +/** + * Get the CodeMirror editor content + */ +export async function getEditorContent(page: Page): Promise { + // 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 { + // 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 { + 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 } + ); +}