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