Merge pull request #98 from AutoMaker-Org/feat/new-e2e-test

Refactor: Restructure test utilities and add context view E2E tests
This commit is contained in:
Web Dev Cody
2025-12-14 22:13:49 -05:00
committed by GitHub
30 changed files with 3417 additions and 2764 deletions

2
.gitignore vendored
View File

@@ -12,4 +12,4 @@ dist/
/.automaker/*
/.automaker/
/old
/logs

View File

@@ -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 */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">

View File

@@ -0,0 +1,688 @@
import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import {
resetContextDirectory,
createContextFileOnDisk,
contextFileExistsOnDisk,
setupProjectWithFixture,
getFixturePath,
navigateToContext,
waitForFileContentToLoad,
switchToEditMode,
waitForContextFile,
selectContextFile,
simulateFileDrop,
setContextEditorContent,
getContextEditorContent,
clickElement,
fillInput,
getByTestId,
waitForNetworkIdle,
} from "./utils";
const WORKSPACE_ROOT = path.resolve(process.cwd(), "../..");
const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, "apps/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);
});
});

View File

@@ -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 = `<app_spec>
<name>Test Project A</name>
<description>A test fixture project for Playwright testing</description>
<tech_stack>
<item>TypeScript</item>
<item>React</item>
</tech_stack>
</app_spec>
`;
/**
* Reset the fixture's app_spec.txt to original content
*/
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<string> {
// CodeMirror uses a contenteditable div with class .cm-content
const content = await page
.locator('[data-testid="spec-editor"] .cm-content')
.textContent();
return content || "";
}
/**
* Set the CodeMirror editor content by selecting all and typing
*/
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement, waitForElementHidden } from "../core/waiting";
/**
* Check if the category autocomplete dropdown is visible
*/
export async function isCategoryAutocompleteListVisible(
page: Page
): Promise<boolean> {
const list = page.locator('[data-testid="category-autocomplete-list"]');
return await list.isVisible();
}
/**
* Wait for the category autocomplete dropdown to be visible
*/
export async function waitForCategoryAutocompleteList(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "category-autocomplete-list", options);
}
/**
* Wait for the category autocomplete dropdown to be hidden
*/
export async function waitForCategoryAutocompleteListHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "category-autocomplete-list", options);
}
/**
* Click a category option in the autocomplete dropdown
*/
export async function clickCategoryOption(
page: Page,
categoryName: string
): Promise<void> {
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
const option = page.locator(`[data-testid="${optionTestId}"]`);
await option.click();
}
/**
* Get a category option element by name
*/
export async function getCategoryOption(
page: Page,
categoryName: string
): Promise<Locator> {
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
return page.locator(`[data-testid="${optionTestId}"]`);
}

View File

@@ -0,0 +1,200 @@
import { Page, Locator } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { waitForElement, waitForElementHidden } from "../core/waiting";
/**
* Check if the add feature dialog is visible
*/
export async function isAddFeatureDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="add-feature-dialog"]');
return await dialog.isVisible().catch(() => false);
}
/**
* Check if the add context file dialog is visible
*/
export async function isAddContextDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="add-context-dialog"]');
return await dialog.isVisible().catch(() => false);
}
/**
* Check if the edit feature dialog is visible
*/
export async function isEditFeatureDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="edit-feature-dialog"]');
return await dialog.isVisible().catch(() => false);
}
/**
* Wait for the edit feature dialog to be visible
*/
export async function waitForEditFeatureDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "edit-feature-dialog", options);
}
/**
* Get the edit feature description input/textarea element
*/
export async function getEditFeatureDescriptionInput(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="edit-feature-description"]');
}
/**
* Check if the edit feature description field is a textarea
*/
export async function isEditFeatureDescriptionTextarea(
page: Page
): Promise<boolean> {
const element = page.locator('[data-testid="edit-feature-description"]');
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
return tagName === "textarea";
}
/**
* Open the edit dialog for a specific feature
*/
export async function openEditFeatureDialog(
page: Page,
featureId: string
): Promise<void> {
await clickElement(page, `edit-feature-${featureId}`);
await waitForEditFeatureDialog(page);
}
/**
* Fill the edit feature description field
*/
export async function fillEditFeatureDescription(
page: Page,
value: string
): Promise<void> {
const input = await getEditFeatureDescriptionInput(page);
await input.fill(value);
}
/**
* Click the confirm edit feature button
*/
export async function confirmEditFeature(page: Page): Promise<void> {
await clickElement(page, "confirm-edit-feature");
}
/**
* Get the delete confirmation dialog
*/
export async function getDeleteConfirmationDialog(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="delete-confirmation-dialog"]');
}
/**
* Check if the delete confirmation dialog is visible
*/
export async function isDeleteConfirmationDialogVisible(
page: Page
): Promise<boolean> {
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
return await dialog.isVisible().catch(() => false);
}
/**
* Wait for the delete confirmation dialog to appear
*/
export async function waitForDeleteConfirmationDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "delete-confirmation-dialog", options);
}
/**
* Wait for the delete confirmation dialog to be hidden
*/
export async function waitForDeleteConfirmationDialogHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "delete-confirmation-dialog", options);
}
/**
* Click the confirm delete button in the delete confirmation dialog
*/
export async function clickConfirmDeleteButton(page: Page): Promise<void> {
await clickElement(page, "confirm-delete-button");
}
/**
* Click the cancel delete button in the delete confirmation dialog
*/
export async function clickCancelDeleteButton(page: Page): Promise<void> {
await clickElement(page, "cancel-delete-button");
}
/**
* Check if the follow-up dialog is visible
*/
export async function isFollowUpDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="follow-up-dialog"]');
return await dialog.isVisible().catch(() => false);
}
/**
* Wait for the follow-up dialog to be visible
*/
export async function waitForFollowUpDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "follow-up-dialog", options);
}
/**
* Wait for the follow-up dialog to be hidden
*/
export async function waitForFollowUpDialogHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "follow-up-dialog", options);
}
/**
* Click the confirm follow-up button in the follow-up dialog
*/
export async function clickConfirmFollowUp(page: Page): Promise<void> {
await clickElement(page, "confirm-follow-up");
}
/**
* Check if the project initialization dialog is visible
*/
export async function isProjectInitDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="project-init-dialog"]');
return await dialog.isVisible();
}
/**
* Wait for the project initialization dialog to appear
*/
export async function waitForProjectInitDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "project-init-dialog", options);
}
/**
* Close the project initialization dialog
*/
export async function closeProjectInitDialog(page: Page): Promise<void> {
const closeButton = page.locator('[data-testid="close-init-dialog"]');
await closeButton.click();
}

View File

@@ -0,0 +1,104 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement, waitForElementHidden } from "../core/waiting";
/**
* Check if the agent output modal is visible
*/
export async function isAgentOutputModalVisible(page: Page): Promise<boolean> {
const modal = page.locator('[data-testid="agent-output-modal"]');
return await modal.isVisible();
}
/**
* Wait for the agent output modal to be visible
*/
export async function waitForAgentOutputModal(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "agent-output-modal", options);
}
/**
* Wait for the agent output modal to be hidden
*/
export async function waitForAgentOutputModalHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "agent-output-modal", options);
}
/**
* Get the modal title/description text to verify which feature's output is being shown
*/
export async function getAgentOutputModalDescription(
page: Page
): Promise<string | null> {
const modal = page.locator('[data-testid="agent-output-modal"]');
const description = modal
.locator('[id="radix-\\:r.+\\:-description"]')
.first();
return await description.textContent().catch(() => null);
}
/**
* Check the dialog description content in the agent output modal
*/
export async function getOutputModalDescription(
page: Page
): Promise<string | null> {
const modalDescription = page.locator(
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
);
return await modalDescription.textContent().catch(() => null);
}
/**
* Get the agent output modal description element
*/
export async function getAgentOutputModalDescriptionElement(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="agent-output-description"]');
}
/**
* Check if the agent output modal description is scrollable
*/
export async function isAgentOutputDescriptionScrollable(
page: Page
): Promise<boolean> {
const description = page.locator('[data-testid="agent-output-description"]');
const scrollInfo = await description.evaluate((el) => {
return {
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
isScrollable: el.scrollHeight > el.clientHeight,
};
});
return scrollInfo.isScrollable;
}
/**
* Get scroll dimensions of the agent output modal description
*/
export async function getAgentOutputDescriptionScrollDimensions(
page: Page
): Promise<{
scrollHeight: number;
clientHeight: number;
maxHeight: string;
overflowY: string;
}> {
const description = page.locator('[data-testid="agent-output-description"]');
return await description.evaluate((el) => {
const style = window.getComputedStyle(el);
return {
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
maxHeight: style.maxHeight,
overflowY: style.overflowY,
};
});
}

View File

@@ -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<Locator> {
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
});
return toast;
}
/**
* Wait for an error toast to appear with specific text
*/
export async function waitForErrorToast(
page: Page,
titleText?: string,
options?: { timeout?: number }
): Promise<Locator> {
// 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<boolean> {
const toastSelector = titleText
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
: '[data-sonner-toast][data-type="error"]';
const toast = page.locator(toastSelector).first();
return await toast.isVisible();
}
/**
* Wait for a success toast to appear with specific text
*/
export async function waitForSuccessToast(
page: Page,
titleText?: string,
options?: { timeout?: number }
): Promise<Locator> {
// Sonner toasts use data-sonner-toast and data-type="success" for success toasts
const toastSelector = titleText
? `[data-sonner-toast][data-type="success"]:has-text("${titleText}")`
: '[data-sonner-toast][data-type="success"]';
const toast = page.locator(toastSelector).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
});
return toast;
}

View File

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

View File

@@ -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<void> {
const element = await getByTestId(page, testId);
await element.click();
}
/**
* Click a button by its text content
*/
export async function clickButtonByText(
page: Page,
text: string
): Promise<void> {
const button = await getButtonByText(page, text);
await button.click();
}
/**
* Fill an input field by its data-testid attribute
*/
export async function fillInput(
page: Page,
testId: string,
value: string
): Promise<void> {
const input = await getByTestId(page, testId);
await input.fill(value);
}
/**
* Press a keyboard shortcut key
*/
export async function pressShortcut(page: Page, key: string): Promise<void> {
await page.keyboard.press(key);
}
/**
* Press a number key (0-9) on the keyboard
*/
export async function pressNumberKey(page: Page, num: number): Promise<void> {
await page.keyboard.press(num.toString());
}
/**
* Focus on an input element to test that shortcuts don't fire when typing
*/
export async function focusOnInput(page: Page, testId: string): Promise<void> {
const input = page.locator(`[data-testid="${testId}"]`);
await input.focus();
}
/**
* Close any open dialog by pressing Escape
*/
export async function closeDialogWithEscape(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await page.waitForTimeout(100); // Give dialog time to close
}

View File

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

View File

@@ -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<void> {
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();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { Page } from "@playwright/test";
/**
* Simulate drag and drop of a file onto an element
*/
export async function simulateFileDrop(
page: Page,
targetSelector: string,
fileName: string,
fileContent: string,
mimeType: string = "text/plain"
): Promise<void> {
await page.evaluate(
({ selector, content, name, mime }) => {
const target = document.querySelector(selector);
if (!target) throw new Error(`Element not found: ${selector}`);
const file = new File([content], name, { type: mime });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
// Dispatch drag events
target.dispatchEvent(
new DragEvent("dragover", {
dataTransfer,
bubbles: true,
})
);
target.dispatchEvent(
new DragEvent("drop", {
dataTransfer,
bubbles: true,
})
);
},
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
);
}

View File

@@ -0,0 +1,50 @@
import { Page, Locator } from "@playwright/test";
/**
* Get the concurrency slider container
*/
export async function getConcurrencySliderContainer(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="concurrency-slider-container"]');
}
/**
* Get the concurrency slider
*/
export async function getConcurrencySlider(page: Page): Promise<Locator> {
return page.locator('[data-testid="concurrency-slider"]');
}
/**
* Get the displayed concurrency value
*/
export async function getConcurrencyValue(page: Page): Promise<string | null> {
const valueElement = page.locator('[data-testid="concurrency-value"]');
return await valueElement.textContent();
}
/**
* Change the concurrency slider value by clicking on the slider track
*/
export async function setConcurrencyValue(
page: Page,
targetValue: number,
min: number = 1,
max: number = 10
): Promise<void> {
const slider = page.locator('[data-testid="concurrency-slider"]');
const sliderBounds = await slider.boundingBox();
if (!sliderBounds) {
throw new Error("Concurrency slider not found or not visible");
}
// Calculate position for target value
const percentage = (targetValue - min) / (max - min);
const targetX = sliderBounds.x + sliderBounds.width * percentage;
const centerY = sliderBounds.y + sliderBounds.height / 2;
// Click at the target position to set the value
await page.mouse.click(targetX, centerY);
}

View File

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

View File

@@ -0,0 +1,58 @@
import { Locator } from "@playwright/test";
/**
* Check if an element is scrollable (has scrollable content)
*/
export async function isElementScrollable(locator: Locator): Promise<boolean> {
const scrollInfo = await locator.evaluate((el) => {
return {
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
isScrollable: el.scrollHeight > el.clientHeight,
};
});
return scrollInfo.isScrollable;
}
/**
* Scroll an element to the bottom
*/
export async function scrollToBottom(locator: Locator): Promise<void> {
await locator.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
}
/**
* Get the scroll position of an element
*/
export async function getScrollPosition(
locator: Locator
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
return await locator.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
}
/**
* Check if an element is visible within a scrollable container
*/
export async function isElementVisibleInScrollContainer(
element: Locator,
container: Locator
): Promise<boolean> {
const elementBox = await element.boundingBox();
const containerBox = await container.boundingBox();
if (!elementBox || !containerBox) {
return false;
}
// Check if element is within the visible area of the container
return (
elementBox.y >= containerBox.y &&
elementBox.y + elementBox.height <= containerBox.y + containerBox.height
);
}

View File

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

View File

@@ -0,0 +1,159 @@
import { Page } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { waitForElement } from "../core/waiting";
/**
* Navigate to the board/kanban view
*/
export async function navigateToBoard(page: Page): Promise<void> {
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Check if we're on the board view already
const boardView = page.locator('[data-testid="board-view"]');
const isOnBoard = await boardView.isVisible().catch(() => false);
if (!isOnBoard) {
// Try to click on a recent project first (from welcome screen)
const recentProject = page.locator('p:has-text("Test Project")').first();
if (await recentProject.isVisible().catch(() => false)) {
await recentProject.click();
await page.waitForTimeout(200);
}
// Then click on Kanban Board nav button to ensure we're on the board
const kanbanNav = page.locator('[data-testid="nav-board"]');
if (await kanbanNav.isVisible().catch(() => false)) {
await kanbanNav.click();
}
}
// Wait for the board view to be visible
await waitForElement(page, "board-view", { timeout: 10000 });
}
/**
* Navigate to the context view
*/
export async function navigateToContext(page: Page): Promise<void> {
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Click on the Context nav button
const contextNav = page.locator('[data-testid="nav-context"]');
if (await contextNav.isVisible().catch(() => false)) {
await contextNav.click();
}
// Wait for the context view to be visible
await waitForElement(page, "context-view", { timeout: 10000 });
}
/**
* Navigate to the spec view
*/
export async function navigateToSpec(page: Page): Promise<void> {
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Click on the Spec nav button
const specNav = page.locator('[data-testid="nav-spec"]');
if (await specNav.isVisible().catch(() => false)) {
await specNav.click();
}
// Wait for the spec view to be visible
await waitForElement(page, "spec-view", { timeout: 10000 });
}
/**
* Navigate to the agent view
*/
export async function navigateToAgent(page: Page): Promise<void> {
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Click on the Agent nav button
const agentNav = page.locator('[data-testid="nav-agent"]');
if (await agentNav.isVisible().catch(() => false)) {
await agentNav.click();
}
// Wait for the agent view to be visible
await waitForElement(page, "agent-view", { timeout: 10000 });
}
/**
* Navigate to the settings view
*/
export async function navigateToSettings(page: Page): Promise<void> {
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Click on the Settings button in the sidebar
const settingsButton = page.locator('[data-testid="settings-button"]');
if (await settingsButton.isVisible().catch(() => false)) {
await settingsButton.click();
}
// Wait for the settings view to be visible
await waitForElement(page, "settings-view", { timeout: 10000 });
}
/**
* Navigate to the setup view directly
* Note: This function uses setupFirstRun from project/setup to avoid circular dependency
*/
export async function navigateToSetup(page: Page): Promise<void> {
// Dynamic import to avoid circular dependency
const { setupFirstRun } = await import("../project/setup");
await setupFirstRun(page);
await page.goto("/");
await page.waitForLoadState("networkidle");
await waitForElement(page, "setup-view", { timeout: 10000 });
}
/**
* Navigate to the welcome view (clear project selection)
*/
export async function navigateToWelcome(page: Page): Promise<void> {
await page.goto("/");
await page.waitForLoadState("networkidle");
await waitForElement(page, "welcome-view", { timeout: 10000 });
}
/**
* Navigate to a specific view using the sidebar navigation
*/
export async function navigateToView(
page: Page,
viewId: string
): Promise<void> {
const navSelector =
viewId === "settings" ? "settings-button" : `nav-${viewId}`;
await clickElement(page, navSelector);
await page.waitForTimeout(100);
}
/**
* Get the current view from the URL or store (checks which view is active)
*/
export async function getCurrentView(page: Page): Promise<string | null> {
// Get the current view from zustand store via localStorage
const storage = await page.evaluate(() => {
const item = localStorage.getItem("automaker-storage");
return item ? JSON.parse(item) : null;
});
return storage?.state?.currentView || null;
}

View File

@@ -0,0 +1,121 @@
import { Page } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
/**
* Resolve the workspace root - handle both running from apps/app and from root
*/
export function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/app")) {
return path.resolve(cwd, "../..");
}
return cwd;
}
const WORKSPACE_ROOT = getWorkspaceRoot();
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
const CONTEXT_PATH = path.join(FIXTURE_PATH, ".automaker/context");
// Original spec content for resetting between tests
const ORIGINAL_SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
<description>A test fixture project for Playwright testing</description>
<tech_stack>
<item>TypeScript</item>
<item>React</item>
</tech_stack>
</app_spec>
`;
/**
* Reset the fixture's app_spec.txt to original content
*/
export function resetFixtureSpec(): void {
const dir = path.dirname(SPEC_FILE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT);
}
/**
* Reset the context directory to empty state
*/
export function resetContextDirectory(): void {
if (fs.existsSync(CONTEXT_PATH)) {
fs.rmSync(CONTEXT_PATH, { recursive: true });
}
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
}
/**
* Create a context file directly on disk (for test setup)
*/
export function createContextFileOnDisk(filename: string, content: string): void {
const filePath = path.join(CONTEXT_PATH, filename);
fs.writeFileSync(filePath, content);
}
/**
* Check if a context file exists on disk
*/
export function contextFileExistsOnDisk(filename: string): boolean {
const filePath = path.join(CONTEXT_PATH, filename);
return fs.existsSync(filePath);
}
/**
* Set up localStorage with a project pointing to our test fixture
* Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
*/
export async function setupProjectWithFixture(
page: Page,
projectPath: string = FIXTURE_PATH
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-fixture",
name: "projectA",
path: pathArg,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
/**
* Get the fixture path
*/
export function getFixturePath(): string {
return FIXTURE_PATH;
}

View File

@@ -0,0 +1,635 @@
import { Page } from "@playwright/test";
/**
* Set up a mock project in localStorage to bypass the welcome screen
* This simulates having opened a project before
*/
export async function setupMockProject(page: Page): Promise<void> {
await page.addInitScript(() => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
});
}
/**
* Set up a mock project with custom concurrency value
*/
export async function setupMockProjectWithConcurrency(
page: Page,
concurrency: number
): Promise<void> {
await page.addInitScript((maxConcurrency: number) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: maxConcurrency,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, concurrency);
}
/**
* Set up a mock project with specific running tasks to simulate concurrency limit
*/
export async function setupMockProjectAtConcurrencyLimit(
page: Page,
maxConcurrency: number = 1,
runningTasks: string[] = ["running-task-1"]
): Promise<void> {
await page.addInitScript(
({
maxConcurrency,
runningTasks,
}: {
maxConcurrency: number;
runningTasks: string[];
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: maxConcurrency,
isAutoModeRunning: false,
runningAutoTasks: runningTasks,
autoModeActivityLog: [],
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
},
{ maxConcurrency, runningTasks }
);
}
/**
* Set up a mock project with features in different states
*/
export async function setupMockProjectWithFeatures(
page: Page,
options?: {
maxConcurrency?: number;
runningTasks?: string[];
features?: Array<{
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
steps?: string[];
}>;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 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<void> {
await page.addInitScript(
({
featureId,
contextContent,
}: {
featureId: string;
contextContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Set up mock file system with a context file for the feature
// This will be used by the mock electron API
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: contextContent,
};
},
{ featureId, contextContent }
);
}
/**
* Set up a mock project with features that have startedAt timestamps
*/
export async function setupMockProjectWithInProgressFeatures(
page: Page,
options?: {
maxConcurrency?: number;
runningTasks?: string[];
features?: Array<{
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
steps?: string[];
startedAt?: string;
}>;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
* Set up a mock project with a specific current view for route persistence testing
*/
export async function setupMockProjectWithView(
page: Page,
view: string
): Promise<void> {
await page.addInitScript((currentView: string) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: currentView,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, view);
}
/**
* Set up an empty localStorage (no projects) to show welcome screen
*/
export async function setupEmptyLocalStorage(page: Page): Promise<void> {
await page.addInitScript(() => {
const mockState = {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 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<void> {
await page.addInitScript(() => {
const mockProjects = [
{
id: "test-project-1",
name: "Test Project 1",
path: "/mock/test-project-1",
lastOpened: new Date().toISOString(),
},
{
id: "test-project-2",
name: "Test Project 2",
path: "/mock/test-project-2",
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
},
];
const mockState = {
state: {
projects: mockProjects,
currentProject: null,
currentView: "welcome",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
});
}
/**
* Set up a mock project with features that have skipTests enabled
*/
export async function setupMockProjectWithSkipTestsFeatures(
page: Page,
options?: {
maxConcurrency?: number;
runningTasks?: string[];
features?: Array<{
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
steps?: string[];
startedAt?: string;
skipTests?: boolean;
}>;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, options);
}
/**
* Set up a mock state with multiple projects
*/
export async function setupMockMultipleProjects(
page: Page,
projectCount: number = 3
): Promise<void> {
await page.addInitScript((count: number) => {
const mockProjects = [];
for (let i = 0; i < count; i++) {
mockProjects.push({
id: `test-project-${i + 1}`,
name: `Test Project ${i + 1}`,
path: `/mock/test-project-${i + 1}`,
lastOpened: new Date(Date.now() - i * 86400000).toISOString(),
});
}
const mockState = {
state: {
projects: mockProjects,
currentProject: mockProjects[0],
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, projectCount);
}
/**
* Set up a mock project with agent output content in the context file
*/
export async function setupMockProjectWithAgentOutput(
page: Page,
featureId: string,
outputContent: string
): Promise<void> {
await page.addInitScript(
({
featureId,
outputContent,
}: {
featureId: string;
outputContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Set up mock file system with output content for the feature
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: outputContent,
};
},
{ featureId, outputContent }
);
}
/**
* Set up a mock project with features that include waiting_approval status
*/
export async function setupMockProjectWithWaitingApprovalFeatures(
page: Page,
options?: {
maxConcurrency?: number;
runningTasks?: string[];
features?: Array<{
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "waiting_approval" | "verified";
steps?: string[];
startedAt?: string;
skipTests?: boolean;
}>;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
* Set up the app store to show setup view (simulate first run)
*/
export async function setupFirstRun(page: Page): Promise<void> {
await page.addInitScript(() => {
// Clear any existing setup state to simulate first run
localStorage.removeItem("automaker-setup");
localStorage.removeItem("automaker-storage");
// Set up the setup store state for first run
const setupState = {
state: {
isFirstRun: true,
setupComplete: false,
currentStep: "welcome",
claudeCliStatus: null,
claudeAuthStatus: null,
claudeInstallProgress: {
isInstalling: false,
currentStep: "",
progress: 0,
output: [],
},
skipClaudeSetup: false,
},
version: 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<void> {
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));
});
}

View File

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

View File

@@ -0,0 +1,112 @@
import { Page, Locator } from "@playwright/test";
/**
* Get a kanban card by feature ID
*/
export async function getKanbanCard(
page: Page,
featureId: string
): Promise<Locator> {
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
}
/**
* Get a kanban column by its ID
*/
export async function getKanbanColumn(
page: Page,
columnId: string
): Promise<Locator> {
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
}
/**
* Get the width of a kanban column
*/
export async function getKanbanColumnWidth(
page: Page,
columnId: string
): Promise<number> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const box = await column.boundingBox();
return box?.width ?? 0;
}
/**
* Check if a kanban column has CSS columns (masonry) layout
*/
export async function hasKanbanColumnMasonryLayout(
page: Page,
columnId: string
): Promise<boolean> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
const columnCount = await contentDiv.evaluate((el) => {
const style = window.getComputedStyle(el);
return style.columnCount;
});
return columnCount === "2";
}
/**
* Drag a kanban card from one column to another
*/
export async function dragKanbanCard(
page: Page,
featureId: string,
targetColumnId: string
): Promise<void> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const targetColumn = page.locator(
`[data-testid="kanban-column-${targetColumnId}"]`
);
// Perform drag and drop
await dragHandle.dragTo(targetColumn);
}
/**
* Click the view output button on a kanban card
*/
export async function clickViewOutput(
page: Page,
featureId: string
): Promise<void> {
// Try the running version first, then the in-progress version
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
const inProgressBtn = page.locator(
`[data-testid="view-output-inprogress-${featureId}"]`
);
if (await runningBtn.isVisible()) {
await runningBtn.click();
} else if (await inProgressBtn.isVisible()) {
await inProgressBtn.click();
} else {
throw new Error(`View output button not found for feature ${featureId}`);
}
}
/**
* Check if the drag handle is visible for a specific feature card
*/
export async function isDragHandleVisibleForFeature(
page: Page,
featureId: string
): Promise<boolean> {
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
return await dragHandle.isVisible().catch(() => false);
}
/**
* Get the drag handle element for a specific feature card
*/
export async function getDragHandleForFeature(
page: Page,
featureId: string
): Promise<Locator> {
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
}

View File

@@ -0,0 +1,185 @@
import { Page, Locator } from "@playwright/test";
import { clickElement, fillInput } from "../core/interactions";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { getByTestId } from "../core/elements";
import { expect } from "@playwright/test";
/**
* Get the context file list element
*/
export async function getContextFileList(page: Page): Promise<Locator> {
return page.locator('[data-testid="context-file-list"]');
}
/**
* Click on a context file in the list
*/
export async function clickContextFile(
page: Page,
fileName: string
): Promise<void> {
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
await fileButton.click();
}
/**
* Get the context editor element
*/
export async function getContextEditor(page: Page): Promise<Locator> {
return page.locator('[data-testid="context-editor"]');
}
/**
* Get the context editor content
*/
export async function getContextEditorContent(page: Page): Promise<string> {
const editor = await getByTestId(page, "context-editor");
return await editor.inputValue();
}
/**
* Set the context editor content
*/
export async function setContextEditorContent(
page: Page,
content: string
): Promise<void> {
const editor = await getByTestId(page, "context-editor");
await editor.fill(content);
}
/**
* Open the add context file dialog
*/
export async function openAddContextFileDialog(page: Page): Promise<void> {
await clickElement(page, "add-context-file");
await waitForElement(page, "add-context-dialog");
}
/**
* Create a text context file via the UI
*/
export async function createContextFile(
page: Page,
filename: string,
content: string
): Promise<void> {
await openAddContextFileDialog(page);
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", filename);
await fillInput(page, "new-file-content", content);
await clickElement(page, "confirm-add-file");
await waitForElementHidden(page, "add-context-dialog");
}
/**
* Create an image context file via the UI
*/
export async function createContextImage(
page: Page,
filename: string,
imagePath: string
): Promise<void> {
await openAddContextFileDialog(page);
await clickElement(page, "add-image-type");
await fillInput(page, "new-file-name", filename);
await page.setInputFiles('[data-testid="image-upload-input"]', imagePath);
await clickElement(page, "confirm-add-file");
await waitForElementHidden(page, "add-context-dialog");
}
/**
* Delete a context file via the UI (must be selected first)
*/
export async function deleteSelectedContextFile(page: Page): Promise<void> {
await clickElement(page, "delete-context-file");
await waitForElement(page, "delete-context-dialog");
await clickElement(page, "confirm-delete-file");
await waitForElementHidden(page, "delete-context-dialog");
}
/**
* Save the current context file
*/
export async function saveContextFile(page: Page): Promise<void> {
await clickElement(page, "save-context-file");
// Wait for save to complete (button shows "Saved")
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="save-context-file"]')
?.textContent?.includes("Saved"),
{ timeout: 5000 }
);
}
/**
* Toggle markdown preview mode
*/
export async function toggleContextPreviewMode(page: Page): Promise<void> {
await clickElement(page, "toggle-preview-mode");
}
/**
* Wait for a specific file to appear in the context file list
*/
export async function waitForContextFile(
page: Page,
filename: string,
timeout: number = 10000
): Promise<void> {
const locator = await getByTestId(page, `context-file-${filename}`);
await locator.waitFor({ state: "visible", timeout });
}
/**
* Click a file in the list and wait for it to be selected (toolbar visible)
* Uses JavaScript click to ensure React event handler fires
*/
export async function selectContextFile(
page: Page,
filename: string,
timeout: number = 10000
): Promise<void> {
const fileButton = await getByTestId(page, `context-file-${filename}`);
await fileButton.waitFor({ state: "visible", timeout });
// Use JavaScript click to ensure React onClick handler fires
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
// Wait for the file to be selected (toolbar with delete button becomes visible)
const deleteButton = await getByTestId(page, "delete-context-file");
await expect(deleteButton).toBeVisible({
timeout,
});
}
/**
* Wait for file content panel to load (either editor, preview, or image)
*/
export async function waitForFileContentToLoad(page: Page): Promise<void> {
// Wait for either the editor, preview, or image to appear
await page.waitForSelector(
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]',
{ timeout: 10000 }
);
}
/**
* Switch from preview mode to edit mode for markdown files
* Markdown files open in preview mode by default, this helper switches to edit mode
*/
export async function switchToEditMode(page: Page): Promise<void> {
// First wait for content to load
await waitForFileContentToLoad(page);
const markdownPreview = await getByTestId(page, "markdown-preview");
const isPreview = await markdownPreview.isVisible().catch(() => false);
if (isPreview) {
await clickElement(page, "toggle-preview-mode");
await page.waitForSelector('[data-testid="context-editor"]', {
timeout: 5000,
});
}
}

View File

@@ -0,0 +1,8 @@
import { Page, Locator } from "@playwright/test";
/**
* Get the settings view scrollable content area
*/
export async function getSettingsContentArea(page: Page): Promise<Locator> {
return page.locator('[data-testid="settings-view"] .overflow-y-auto');
}

View File

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

View File

@@ -0,0 +1,118 @@
import { Page, Locator } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { navigateToSpec } from "../navigation/views";
/**
* Get the spec editor element
*/
export async function getSpecEditor(page: Page): Promise<Locator> {
return page.locator('[data-testid="spec-editor"]');
}
/**
* Get the spec editor content
*/
export async function getSpecEditorContent(page: Page): Promise<string> {
const editor = await getSpecEditor(page);
return await editor.inputValue();
}
/**
* Set the spec editor content
*/
export async function setSpecEditorContent(
page: Page,
content: string
): Promise<void> {
const editor = await getSpecEditor(page);
await editor.fill(content);
}
/**
* Click the save spec button
*/
export async function clickSaveSpec(page: Page): Promise<void> {
await clickElement(page, "save-spec");
}
/**
* Click the reload spec button
*/
export async function clickReloadSpec(page: Page): Promise<void> {
await clickElement(page, "reload-spec");
}
/**
* Check if the spec view path display shows the correct .automaker path
*/
export async function getDisplayedSpecPath(page: Page): Promise<string | null> {
const specView = page.locator('[data-testid="spec-view"]');
const pathElement = specView.locator("p.text-muted-foreground").first();
return await pathElement.textContent();
}
/**
* Navigate to the spec editor view
*/
export async function navigateToSpecEditor(page: Page): Promise<void> {
await navigateToSpec(page);
}
/**
* Get the CodeMirror editor content
*/
export async function getEditorContent(page: Page): Promise<string> {
// CodeMirror uses a contenteditable div with class .cm-content
const content = await page
.locator('[data-testid="spec-editor"] .cm-content')
.textContent();
return content || "";
}
/**
* Set the CodeMirror editor content by selecting all and typing
*/
export async function setEditorContent(page: Page, content: string): Promise<void> {
// Click on the editor to focus it
const editor = page.locator('[data-testid="spec-editor"] .cm-content');
await editor.click();
// Wait for focus
await page.waitForTimeout(200);
// Select all content (Cmd+A on Mac, Ctrl+A on others)
const isMac = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
// Wait for selection
await page.waitForTimeout(100);
// Delete the selected content first
await page.keyboard.press("Backspace");
// Wait for deletion
await page.waitForTimeout(100);
// Type the new content
await page.keyboard.type(content, { delay: 10 });
// Wait for typing to complete
await page.waitForTimeout(200);
}
/**
* Click the save button
*/
export async function clickSaveButton(page: Page): Promise<void> {
const saveButton = page.locator('[data-testid="save-spec"]');
await saveButton.click();
// Wait for the button text to change to "Saved" indicating save is complete
await page.waitForFunction(
() => {
const btn = document.querySelector('[data-testid="save-spec"]');
return btn?.textContent?.includes("Saved");
},
{ timeout: 5000 }
);
}