mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,4 +12,4 @@ dist/
|
|||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
/old
|
/logs
|
||||||
@@ -393,6 +393,7 @@ export function ContextView() {
|
|||||||
className="flex-1 flex overflow-hidden"
|
className="flex-1 flex overflow-hidden"
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
data-testid="context-drop-zone"
|
||||||
>
|
>
|
||||||
{/* Left Panel - File List */}
|
{/* Left Panel - File List */}
|
||||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||||
|
|||||||
688
apps/app/tests/context-view.spec.ts
Normal file
688
apps/app/tests/context-view.spec.ts
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import {
|
||||||
|
resetContextDirectory,
|
||||||
|
createContextFileOnDisk,
|
||||||
|
contextFileExistsOnDisk,
|
||||||
|
setupProjectWithFixture,
|
||||||
|
getFixturePath,
|
||||||
|
navigateToContext,
|
||||||
|
waitForFileContentToLoad,
|
||||||
|
switchToEditMode,
|
||||||
|
waitForContextFile,
|
||||||
|
selectContextFile,
|
||||||
|
simulateFileDrop,
|
||||||
|
setContextEditorContent,
|
||||||
|
getContextEditorContent,
|
||||||
|
clickElement,
|
||||||
|
fillInput,
|
||||||
|
getByTestId,
|
||||||
|
waitForNetworkIdle,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const WORKSPACE_ROOT = path.resolve(process.cwd(), "../..");
|
||||||
|
const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, "apps/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,157 +1,18 @@
|
|||||||
import { test, expect, Page } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import * as fs from "fs";
|
import {
|
||||||
import * as path from "path";
|
resetFixtureSpec,
|
||||||
|
setupProjectWithFixture,
|
||||||
// Resolve the workspace root - handle both running from apps/app and from root
|
getFixturePath,
|
||||||
function getWorkspaceRoot(): string {
|
navigateToSpecEditor,
|
||||||
const cwd = process.cwd();
|
getEditorContent,
|
||||||
if (cwd.includes("apps/app")) {
|
setEditorContent,
|
||||||
return path.resolve(cwd, "../..");
|
clickSaveButton,
|
||||||
}
|
getByTestId,
|
||||||
return cwd;
|
clickElement,
|
||||||
}
|
fillInput,
|
||||||
|
waitForNetworkIdle,
|
||||||
const WORKSPACE_ROOT = getWorkspaceRoot();
|
waitForElement,
|
||||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
|
} from "./utils";
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Spec Editor Persistence", () => {
|
test.describe("Spec Editor Persistence", () => {
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
@@ -168,31 +29,29 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Use the resolved fixture path
|
// Use the resolved fixture path
|
||||||
const fixturePath = FIXTURE_PATH;
|
const fixturePath = getFixturePath();
|
||||||
|
|
||||||
// Step 1: Set up the project in localStorage pointing to our fixture
|
// Step 1: Set up the project in localStorage pointing to our fixture
|
||||||
await setupProjectWithFixture(page, fixturePath);
|
await setupProjectWithFixture(page, fixturePath);
|
||||||
|
|
||||||
// Step 2: Navigate to the app
|
// Step 2: Navigate to the app
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Step 3: Verify we're on the dashboard with the project loaded
|
// Step 3: Verify we're on the dashboard with the project loaded
|
||||||
// The sidebar should show the project selector
|
// 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 });
|
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
|
||||||
// Step 4: Click on the Spec Editor in the sidebar
|
// Step 4: Click on the Spec Editor in the sidebar
|
||||||
await navigateToSpecEditor(page);
|
await navigateToSpecEditor(page);
|
||||||
|
|
||||||
// Step 5: Wait for the spec editor to load
|
// 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 });
|
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
|
||||||
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
|
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
|
||||||
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
|
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small delay to ensure editor is fully initialized
|
// Small delay to ensure editor is fully initialized
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
@@ -205,19 +64,18 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
|
|
||||||
// Step 9: Refresh the page
|
// Step 9: Refresh the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Step 10: Navigate back to the spec editor
|
// Step 10: Navigate back to the spec editor
|
||||||
// After reload, we need to wait for the app to initialize
|
// 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
|
// Navigate to spec editor again
|
||||||
await navigateToSpecEditor(page);
|
await navigateToSpecEditor(page);
|
||||||
|
|
||||||
// Wait for CodeMirror to be ready
|
// Wait for CodeMirror to be ready
|
||||||
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
|
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
||||||
timeout: 10000,
|
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||||
});
|
|
||||||
|
|
||||||
// Small delay to ensure editor content is loaded
|
// Small delay to ensure editor content is loaded
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
@@ -269,16 +127,14 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
|
|
||||||
// Navigate to the app
|
// Navigate to the app
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Wait for the sidebar to be visible
|
// 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 });
|
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
|
||||||
// Click the Open Project button
|
// Click the Open Project button
|
||||||
const openProjectButton = page.locator(
|
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||||
'[data-testid="open-project-button"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the button is visible (it might not be in collapsed sidebar)
|
// Check if the button is visible (it might not be in collapsed sidebar)
|
||||||
const isButtonVisible = await openProjectButton
|
const isButtonVisible = await openProjectButton
|
||||||
@@ -286,7 +142,7 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (isButtonVisible) {
|
if (isButtonVisible) {
|
||||||
await openProjectButton.click();
|
await clickElement(page, "open-project-button");
|
||||||
|
|
||||||
// The file browser dialog should open
|
// The file browser dialog should open
|
||||||
// Note: In web mode, this might use the FileBrowserDialog component
|
// 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
|
// Navigate to app first
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Set up localStorage state (without a current project, but mark setup complete)
|
// Set up localStorage state (without a current project, but mark setup complete)
|
||||||
// Using evaluate instead of addInitScript so it only runs once
|
// 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
|
// Reload to apply the localStorage state
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Wait for sidebar
|
// Wait for sidebar
|
||||||
await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 });
|
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||||
|
|
||||||
// Click the Open Project button
|
// Click the Open Project button
|
||||||
const openProjectButton = page.locator(
|
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||||
'[data-testid="open-project-button"]'
|
|
||||||
);
|
|
||||||
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
||||||
await openProjectButton.click();
|
await clickElement(page, "open-project-button");
|
||||||
|
|
||||||
// Wait for the file browser dialog to open
|
// Wait for the file browser dialog to open
|
||||||
const dialogTitle = page.locator('text="Select Project Directory"');
|
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
|
// 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 });
|
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
|
||||||
// Clear the input and type the full path to the fixture
|
// 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
|
// Click the Go button to navigate to the path
|
||||||
const goButton = page.locator('[data-testid="go-to-path-button"]');
|
await clickElement(page, "go-to-path-button");
|
||||||
await goButton.click();
|
|
||||||
|
|
||||||
// Wait for loading to complete
|
// Wait for loading to complete
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -435,15 +288,14 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Navigate to spec editor
|
// 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.waitFor({ state: "visible", timeout: 10000 });
|
||||||
await specNav.click();
|
await clickElement(page, "nav-spec");
|
||||||
|
|
||||||
// Wait for spec view with the editor (not the empty state)
|
// Wait for spec view with the editor (not the empty state)
|
||||||
await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 });
|
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||||
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
|
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
||||||
timeout: 10000,
|
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Edit the content
|
// Edit the content
|
||||||
@@ -454,15 +306,14 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
|
|
||||||
// Refresh and verify persistence
|
// Refresh and verify persistence
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Navigate back to spec editor
|
// Navigate back to spec editor
|
||||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||||
await specNav.click();
|
await clickElement(page, "nav-spec");
|
||||||
|
|
||||||
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
|
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
||||||
timeout: 10000,
|
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify the content persisted
|
// Verify the content persisted
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
59
apps/app/tests/utils/components/autocomplete.ts
Normal file
59
apps/app/tests/utils/components/autocomplete.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the category autocomplete dropdown is visible
|
||||||
|
*/
|
||||||
|
export async function isCategoryAutocompleteListVisible(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const list = page.locator('[data-testid="category-autocomplete-list"]');
|
||||||
|
return await list.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the category autocomplete dropdown to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForCategoryAutocompleteList(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "category-autocomplete-list", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the category autocomplete dropdown to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForCategoryAutocompleteListHidden(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForElementHidden(page, "category-autocomplete-list", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a category option in the autocomplete dropdown
|
||||||
|
*/
|
||||||
|
export async function clickCategoryOption(
|
||||||
|
page: Page,
|
||||||
|
categoryName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const optionTestId = `category-option-${categoryName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "-")}`;
|
||||||
|
const option = page.locator(`[data-testid="${optionTestId}"]`);
|
||||||
|
await option.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a category option element by name
|
||||||
|
*/
|
||||||
|
export async function getCategoryOption(
|
||||||
|
page: Page,
|
||||||
|
categoryName: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
const optionTestId = `category-option-${categoryName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "-")}`;
|
||||||
|
return page.locator(`[data-testid="${optionTestId}"]`);
|
||||||
|
}
|
||||||
200
apps/app/tests/utils/components/dialogs.ts
Normal file
200
apps/app/tests/utils/components/dialogs.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { clickElement } from "../core/interactions";
|
||||||
|
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the add feature dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isAddFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="add-feature-dialog"]');
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the add context file dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isAddContextDialogVisible(page: Page): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="add-context-dialog"]');
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the edit feature dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isEditFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="edit-feature-dialog"]');
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the edit feature dialog to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForEditFeatureDialog(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "edit-feature-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the edit feature description input/textarea element
|
||||||
|
*/
|
||||||
|
export async function getEditFeatureDescriptionInput(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="edit-feature-description"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the edit feature description field is a textarea
|
||||||
|
*/
|
||||||
|
export async function isEditFeatureDescriptionTextarea(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const element = page.locator('[data-testid="edit-feature-description"]');
|
||||||
|
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
|
||||||
|
return tagName === "textarea";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the edit dialog for a specific feature
|
||||||
|
*/
|
||||||
|
export async function openEditFeatureDialog(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await clickElement(page, `edit-feature-${featureId}`);
|
||||||
|
await waitForEditFeatureDialog(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the edit feature description field
|
||||||
|
*/
|
||||||
|
export async function fillEditFeatureDescription(
|
||||||
|
page: Page,
|
||||||
|
value: string
|
||||||
|
): Promise<void> {
|
||||||
|
const input = await getEditFeatureDescriptionInput(page);
|
||||||
|
await input.fill(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the confirm edit feature button
|
||||||
|
*/
|
||||||
|
export async function confirmEditFeature(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "confirm-edit-feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the delete confirmation dialog
|
||||||
|
*/
|
||||||
|
export async function getDeleteConfirmationDialog(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the delete confirmation dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isDeleteConfirmationDialogVisible(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the delete confirmation dialog to appear
|
||||||
|
*/
|
||||||
|
export async function waitForDeleteConfirmationDialog(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "delete-confirmation-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the delete confirmation dialog to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForDeleteConfirmationDialogHidden(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForElementHidden(page, "delete-confirmation-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the confirm delete button in the delete confirmation dialog
|
||||||
|
*/
|
||||||
|
export async function clickConfirmDeleteButton(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "confirm-delete-button");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the cancel delete button in the delete confirmation dialog
|
||||||
|
*/
|
||||||
|
export async function clickCancelDeleteButton(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "cancel-delete-button");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the follow-up dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isFollowUpDialogVisible(page: Page): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="follow-up-dialog"]');
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the follow-up dialog to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForFollowUpDialog(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "follow-up-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the follow-up dialog to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForFollowUpDialogHidden(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForElementHidden(page, "follow-up-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the confirm follow-up button in the follow-up dialog
|
||||||
|
*/
|
||||||
|
export async function clickConfirmFollowUp(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "confirm-follow-up");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the project initialization dialog is visible
|
||||||
|
*/
|
||||||
|
export async function isProjectInitDialogVisible(page: Page): Promise<boolean> {
|
||||||
|
const dialog = page.locator('[data-testid="project-init-dialog"]');
|
||||||
|
return await dialog.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the project initialization dialog to appear
|
||||||
|
*/
|
||||||
|
export async function waitForProjectInitDialog(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "project-init-dialog", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the project initialization dialog
|
||||||
|
*/
|
||||||
|
export async function closeProjectInitDialog(page: Page): Promise<void> {
|
||||||
|
const closeButton = page.locator('[data-testid="close-init-dialog"]');
|
||||||
|
await closeButton.click();
|
||||||
|
}
|
||||||
104
apps/app/tests/utils/components/modals.ts
Normal file
104
apps/app/tests/utils/components/modals.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the agent output modal is visible
|
||||||
|
*/
|
||||||
|
export async function isAgentOutputModalVisible(page: Page): Promise<boolean> {
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
return await modal.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the agent output modal to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForAgentOutputModal(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "agent-output-modal", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the agent output modal to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForAgentOutputModalHidden(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForElementHidden(page, "agent-output-modal", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the modal title/description text to verify which feature's output is being shown
|
||||||
|
*/
|
||||||
|
export async function getAgentOutputModalDescription(
|
||||||
|
page: Page
|
||||||
|
): Promise<string | null> {
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const description = modal
|
||||||
|
.locator('[id="radix-\\:r.+\\:-description"]')
|
||||||
|
.first();
|
||||||
|
return await description.textContent().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the dialog description content in the agent output modal
|
||||||
|
*/
|
||||||
|
export async function getOutputModalDescription(
|
||||||
|
page: Page
|
||||||
|
): Promise<string | null> {
|
||||||
|
const modalDescription = page.locator(
|
||||||
|
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
|
||||||
|
);
|
||||||
|
return await modalDescription.textContent().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the agent output modal description element
|
||||||
|
*/
|
||||||
|
export async function getAgentOutputModalDescriptionElement(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="agent-output-description"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the agent output modal description is scrollable
|
||||||
|
*/
|
||||||
|
export async function isAgentOutputDescriptionScrollable(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const description = page.locator('[data-testid="agent-output-description"]');
|
||||||
|
const scrollInfo = await description.evaluate((el) => {
|
||||||
|
return {
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
isScrollable: el.scrollHeight > el.clientHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return scrollInfo.isScrollable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scroll dimensions of the agent output modal description
|
||||||
|
*/
|
||||||
|
export async function getAgentOutputDescriptionScrollDimensions(
|
||||||
|
page: Page
|
||||||
|
): Promise<{
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
maxHeight: string;
|
||||||
|
overflowY: string;
|
||||||
|
}> {
|
||||||
|
const description = page.locator('[data-testid="agent-output-description"]');
|
||||||
|
return await description.evaluate((el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
maxHeight: style.maxHeight,
|
||||||
|
overflowY: style.overflowY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/app/tests/utils/components/toasts.ts
Normal file
75
apps/app/tests/utils/components/toasts.ts
Normal 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;
|
||||||
|
}
|
||||||
40
apps/app/tests/utils/core/elements.ts
Normal file
40
apps/app/tests/utils/core/elements.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an element by its data-testid attribute
|
||||||
|
*/
|
||||||
|
export async function getByTestId(
|
||||||
|
page: Page,
|
||||||
|
testId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a button by its text content
|
||||||
|
*/
|
||||||
|
export async function getButtonByText(
|
||||||
|
page: Page,
|
||||||
|
text: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`button:has-text("${text}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category autocomplete input element
|
||||||
|
*/
|
||||||
|
export async function getCategoryAutocompleteInput(
|
||||||
|
page: Page,
|
||||||
|
testId: string = "feature-category-input"
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category autocomplete dropdown list
|
||||||
|
*/
|
||||||
|
export async function getCategoryAutocompleteList(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="category-autocomplete-list"]');
|
||||||
|
}
|
||||||
63
apps/app/tests/utils/core/interactions.ts
Normal file
63
apps/app/tests/utils/core/interactions.ts
Normal 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
|
||||||
|
}
|
||||||
40
apps/app/tests/utils/core/waiting.ts
Normal file
40
apps/app/tests/utils/core/waiting.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the page to reach network idle state
|
||||||
|
* This is commonly used after navigation or page reload to ensure all network requests have completed
|
||||||
|
*/
|
||||||
|
export async function waitForNetworkIdle(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element with a specific data-testid to appear
|
||||||
|
*/
|
||||||
|
export async function waitForElement(
|
||||||
|
page: Page,
|
||||||
|
testId: string,
|
||||||
|
options?: { timeout?: number; state?: "attached" | "visible" | "hidden" }
|
||||||
|
): Promise<Locator> {
|
||||||
|
const element = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
await element.waitFor({
|
||||||
|
timeout: options?.timeout ?? 5000,
|
||||||
|
state: options?.state ?? "visible",
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element with a specific data-testid to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForElementHidden(
|
||||||
|
page: Page,
|
||||||
|
testId: string,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const element = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
await element.waitFor({
|
||||||
|
timeout: options?.timeout ?? 5000,
|
||||||
|
state: "hidden",
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/app/tests/utils/features/kanban.ts
Normal file
34
apps/app/tests/utils/features/kanban.ts
Normal 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();
|
||||||
|
}
|
||||||
114
apps/app/tests/utils/features/skip-tests.ts
Normal file
114
apps/app/tests/utils/features/skip-tests.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the skip tests checkbox element in the add feature dialog
|
||||||
|
*/
|
||||||
|
export async function getSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="skip-tests-checkbox"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the skip tests checkbox in the add feature dialog
|
||||||
|
*/
|
||||||
|
export async function toggleSkipTestsCheckbox(page: Page): Promise<void> {
|
||||||
|
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
|
||||||
|
await checkbox.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the skip tests checkbox is checked in the add feature dialog
|
||||||
|
*/
|
||||||
|
export async function isSkipTestsChecked(page: Page): Promise<boolean> {
|
||||||
|
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
|
||||||
|
const state = await checkbox.getAttribute("data-state");
|
||||||
|
return state === "checked";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the edit skip tests checkbox element in the edit feature dialog
|
||||||
|
*/
|
||||||
|
export async function getEditSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the skip tests checkbox in the edit feature dialog
|
||||||
|
*/
|
||||||
|
export async function toggleEditSkipTestsCheckbox(page: Page): Promise<void> {
|
||||||
|
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||||
|
await checkbox.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the skip tests checkbox is checked in the edit feature dialog
|
||||||
|
*/
|
||||||
|
export async function isEditSkipTestsChecked(page: Page): Promise<boolean> {
|
||||||
|
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||||
|
const state = await checkbox.getAttribute("data-state");
|
||||||
|
return state === "checked";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the skip tests badge is visible on a kanban card
|
||||||
|
*/
|
||||||
|
export async function isSkipTestsBadgeVisible(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const badge = page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
|
||||||
|
return await badge.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the skip tests badge element for a kanban card
|
||||||
|
*/
|
||||||
|
export async function getSkipTestsBadge(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the manual verify button for a skipTests feature
|
||||||
|
*/
|
||||||
|
export async function clickManualVerify(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the manual verify button is visible for a feature
|
||||||
|
*/
|
||||||
|
export async function isManualVerifyButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
|
||||||
|
return await button.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the move back button for a verified skipTests feature
|
||||||
|
*/
|
||||||
|
export async function clickMoveBack(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the move back button is visible for a feature
|
||||||
|
*/
|
||||||
|
export async function isMoveBackButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
|
||||||
|
return await button.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
36
apps/app/tests/utils/features/timers.ts
Normal file
36
apps/app/tests/utils/features/timers.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count up timer element for a specific feature card
|
||||||
|
*/
|
||||||
|
export async function getTimerForFeature(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
return card.locator('[data-testid="count-up-timer"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the timer display text for a specific feature card
|
||||||
|
*/
|
||||||
|
export async function getTimerDisplayForFeature(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
const timerDisplay = card.locator('[data-testid="timer-display"]');
|
||||||
|
return await timerDisplay.textContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a timer is visible for a specific feature
|
||||||
|
*/
|
||||||
|
export async function isTimerVisibleForFeature(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
const timer = card.locator('[data-testid="count-up-timer"]');
|
||||||
|
return await timer.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
82
apps/app/tests/utils/features/waiting-approval.ts
Normal file
82
apps/app/tests/utils/features/waiting-approval.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the follow-up button for a waiting_approval feature
|
||||||
|
*/
|
||||||
|
export async function getFollowUpButton(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the follow-up button for a waiting_approval feature
|
||||||
|
*/
|
||||||
|
export async function clickFollowUpButton(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the follow-up button is visible for a feature
|
||||||
|
*/
|
||||||
|
export async function isFollowUpButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||||
|
return await button.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the commit button for a waiting_approval feature
|
||||||
|
*/
|
||||||
|
export async function getCommitButton(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="commit-${featureId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the commit button for a waiting_approval feature
|
||||||
|
*/
|
||||||
|
export async function clickCommitButton(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the commit button is visible for a feature
|
||||||
|
*/
|
||||||
|
export async function isCommitButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||||
|
return await button.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the waiting_approval kanban column
|
||||||
|
*/
|
||||||
|
export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the waiting_approval column is visible
|
||||||
|
*/
|
||||||
|
export async function isWaitingApprovalColumnVisible(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||||
|
return await column.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
38
apps/app/tests/utils/files/drag-drop.ts
Normal file
38
apps/app/tests/utils/files/drag-drop.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/app/tests/utils/helpers/concurrency.ts
Normal file
50
apps/app/tests/utils/helpers/concurrency.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the concurrency slider container
|
||||||
|
*/
|
||||||
|
export async function getConcurrencySliderContainer(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="concurrency-slider-container"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the concurrency slider
|
||||||
|
*/
|
||||||
|
export async function getConcurrencySlider(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="concurrency-slider"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the displayed concurrency value
|
||||||
|
*/
|
||||||
|
export async function getConcurrencyValue(page: Page): Promise<string | null> {
|
||||||
|
const valueElement = page.locator('[data-testid="concurrency-value"]');
|
||||||
|
return await valueElement.textContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the concurrency slider value by clicking on the slider track
|
||||||
|
*/
|
||||||
|
export async function setConcurrencyValue(
|
||||||
|
page: Page,
|
||||||
|
targetValue: number,
|
||||||
|
min: number = 1,
|
||||||
|
max: number = 10
|
||||||
|
): Promise<void> {
|
||||||
|
const slider = page.locator('[data-testid="concurrency-slider"]');
|
||||||
|
const sliderBounds = await slider.boundingBox();
|
||||||
|
|
||||||
|
if (!sliderBounds) {
|
||||||
|
throw new Error("Concurrency slider not found or not visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position for target value
|
||||||
|
const percentage = (targetValue - min) / (max - min);
|
||||||
|
const targetX = sliderBounds.x + sliderBounds.width * percentage;
|
||||||
|
const centerY = sliderBounds.y + sliderBounds.height / 2;
|
||||||
|
|
||||||
|
// Click at the target position to set the value
|
||||||
|
await page.mouse.click(targetX, centerY);
|
||||||
|
}
|
||||||
137
apps/app/tests/utils/helpers/log-viewer.ts
Normal file
137
apps/app/tests/utils/helpers/log-viewer.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { clickElement } from "../core/interactions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the log viewer header element (contains type counts and expand/collapse buttons)
|
||||||
|
*/
|
||||||
|
export async function getLogViewerHeader(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="log-viewer-header"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the log viewer header is visible
|
||||||
|
*/
|
||||||
|
export async function isLogViewerHeaderVisible(page: Page): Promise<boolean> {
|
||||||
|
const header = page.locator('[data-testid="log-viewer-header"]');
|
||||||
|
return await header.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the log entries container element
|
||||||
|
*/
|
||||||
|
export async function getLogEntriesContainer(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="log-entries-container"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a log entry by its type
|
||||||
|
*/
|
||||||
|
export async function getLogEntryByType(
|
||||||
|
page: Page,
|
||||||
|
type: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="log-entry-${type}"]`).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all log entries of a specific type
|
||||||
|
*/
|
||||||
|
export async function getAllLogEntriesByType(
|
||||||
|
page: Page,
|
||||||
|
type: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="log-entry-${type}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count log entries of a specific type
|
||||||
|
*/
|
||||||
|
export async function countLogEntriesByType(
|
||||||
|
page: Page,
|
||||||
|
type: string
|
||||||
|
): Promise<number> {
|
||||||
|
const entries = page.locator(`[data-testid="log-entry-${type}"]`);
|
||||||
|
return await entries.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the log type count badge by type
|
||||||
|
*/
|
||||||
|
export async function getLogTypeCountBadge(
|
||||||
|
page: Page,
|
||||||
|
type: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a log type count badge is visible
|
||||||
|
*/
|
||||||
|
export async function isLogTypeCountBadgeVisible(
|
||||||
|
page: Page,
|
||||||
|
type: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const badge = page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||||
|
return await badge.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the expand all button in the log viewer
|
||||||
|
*/
|
||||||
|
export async function clickLogExpandAll(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "log-expand-all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the collapse all button in the log viewer
|
||||||
|
*/
|
||||||
|
export async function clickLogCollapseAll(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "log-collapse-all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a log entry badge element
|
||||||
|
*/
|
||||||
|
export async function getLogEntryBadge(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="log-entry-badge"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any log entry badge is visible
|
||||||
|
*/
|
||||||
|
export async function isLogEntryBadgeVisible(page: Page): Promise<boolean> {
|
||||||
|
const badge = page.locator('[data-testid="log-entry-badge"]').first();
|
||||||
|
return await badge.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the view mode toggle button (parsed/raw)
|
||||||
|
*/
|
||||||
|
export async function getViewModeButton(
|
||||||
|
page: Page,
|
||||||
|
mode: "parsed" | "raw"
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a view mode toggle button
|
||||||
|
*/
|
||||||
|
export async function clickViewModeButton(
|
||||||
|
page: Page,
|
||||||
|
mode: "parsed" | "raw"
|
||||||
|
): Promise<void> {
|
||||||
|
await clickElement(page, `view-mode-${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a view mode button is active (selected)
|
||||||
|
*/
|
||||||
|
export async function isViewModeActive(
|
||||||
|
page: Page,
|
||||||
|
mode: "parsed" | "raw"
|
||||||
|
): Promise<boolean> {
|
||||||
|
const button = page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||||
|
const classes = await button.getAttribute("class");
|
||||||
|
return classes?.includes("text-purple-300") ?? false;
|
||||||
|
}
|
||||||
58
apps/app/tests/utils/helpers/scroll.ts
Normal file
58
apps/app/tests/utils/helpers/scroll.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element is scrollable (has scrollable content)
|
||||||
|
*/
|
||||||
|
export async function isElementScrollable(locator: Locator): Promise<boolean> {
|
||||||
|
const scrollInfo = await locator.evaluate((el) => {
|
||||||
|
return {
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
isScrollable: el.scrollHeight > el.clientHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return scrollInfo.isScrollable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll an element to the bottom
|
||||||
|
*/
|
||||||
|
export async function scrollToBottom(locator: Locator): Promise<void> {
|
||||||
|
await locator.evaluate((el) => {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the scroll position of an element
|
||||||
|
*/
|
||||||
|
export async function getScrollPosition(
|
||||||
|
locator: Locator
|
||||||
|
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
|
||||||
|
return await locator.evaluate((el) => ({
|
||||||
|
scrollTop: el.scrollTop,
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element is visible within a scrollable container
|
||||||
|
*/
|
||||||
|
export async function isElementVisibleInScrollContainer(
|
||||||
|
element: Locator,
|
||||||
|
container: Locator
|
||||||
|
): Promise<boolean> {
|
||||||
|
const elementBox = await element.boundingBox();
|
||||||
|
const containerBox = await container.boundingBox();
|
||||||
|
|
||||||
|
if (!elementBox || !containerBox) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element is within the visible area of the container
|
||||||
|
return (
|
||||||
|
elementBox.y >= containerBox.y &&
|
||||||
|
elementBox.y + elementBox.height <= containerBox.y + containerBox.height
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/app/tests/utils/index.ts
Normal file
41
apps/app/tests/utils/index.ts
Normal 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";
|
||||||
159
apps/app/tests/utils/navigation/views.ts
Normal file
159
apps/app/tests/utils/navigation/views.ts
Normal 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;
|
||||||
|
}
|
||||||
121
apps/app/tests/utils/project/fixtures.ts
Normal file
121
apps/app/tests/utils/project/fixtures.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Page } from "@playwright/test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the workspace root - handle both running from apps/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;
|
||||||
|
}
|
||||||
635
apps/app/tests/utils/project/setup.ts
Normal file
635
apps/app/tests/utils/project/setup.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
98
apps/app/tests/utils/views/agent.ts
Normal file
98
apps/app/tests/utils/views/agent.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { waitForElement } from "../core/waiting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the session list element
|
||||||
|
*/
|
||||||
|
export async function getSessionList(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="session-list"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the new session button
|
||||||
|
*/
|
||||||
|
export async function getNewSessionButton(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="new-session-button"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the new session button
|
||||||
|
*/
|
||||||
|
export async function clickNewSessionButton(page: Page): Promise<void> {
|
||||||
|
const button = await getNewSessionButton(page);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a session item by its ID
|
||||||
|
*/
|
||||||
|
export async function getSessionItem(
|
||||||
|
page: Page,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="session-item-${sessionId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the archive button for a session
|
||||||
|
*/
|
||||||
|
export async function clickArchiveSession(
|
||||||
|
page: Page,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = page.locator(`[data-testid="archive-session-${sessionId}"]`);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the no session placeholder is visible
|
||||||
|
*/
|
||||||
|
export async function isNoSessionPlaceholderVisible(
|
||||||
|
page: Page
|
||||||
|
): Promise<boolean> {
|
||||||
|
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
|
||||||
|
return await placeholder.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the no session placeholder to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForNoSessionPlaceholder(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "no-session-placeholder", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the message list is visible (indicates a session is selected)
|
||||||
|
*/
|
||||||
|
export async function isMessageListVisible(page: Page): Promise<boolean> {
|
||||||
|
const messageList = page.locator('[data-testid="message-list"]');
|
||||||
|
return await messageList.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of session items in the session list
|
||||||
|
*/
|
||||||
|
export async function countSessionItems(page: Page): Promise<number> {
|
||||||
|
const sessionList = page.locator(
|
||||||
|
'[data-testid="session-list"] [data-testid^="session-item-"]'
|
||||||
|
);
|
||||||
|
return await sessionList.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a new session to be created (by checking if a session item appears)
|
||||||
|
*/
|
||||||
|
export async function waitForNewSession(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
// Wait for any session item to appear
|
||||||
|
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
|
||||||
|
await sessionItem.waitFor({
|
||||||
|
timeout: options?.timeout ?? 5000,
|
||||||
|
state: "visible",
|
||||||
|
});
|
||||||
|
}
|
||||||
112
apps/app/tests/utils/views/board.ts
Normal file
112
apps/app/tests/utils/views/board.ts
Normal 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}"]`);
|
||||||
|
}
|
||||||
185
apps/app/tests/utils/views/context.ts
Normal file
185
apps/app/tests/utils/views/context.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { clickElement, fillInput } from "../core/interactions";
|
||||||
|
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||||
|
import { getByTestId } from "../core/elements";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context file list element
|
||||||
|
*/
|
||||||
|
export async function getContextFileList(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="context-file-list"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on a context file in the list
|
||||||
|
*/
|
||||||
|
export async function clickContextFile(
|
||||||
|
page: Page,
|
||||||
|
fileName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
||||||
|
await fileButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context editor element
|
||||||
|
*/
|
||||||
|
export async function getContextEditor(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="context-editor"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context editor content
|
||||||
|
*/
|
||||||
|
export async function getContextEditorContent(page: Page): Promise<string> {
|
||||||
|
const editor = await getByTestId(page, "context-editor");
|
||||||
|
return await editor.inputValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the context editor content
|
||||||
|
*/
|
||||||
|
export async function setContextEditorContent(
|
||||||
|
page: Page,
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
const editor = await getByTestId(page, "context-editor");
|
||||||
|
await editor.fill(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the add context file dialog
|
||||||
|
*/
|
||||||
|
export async function openAddContextFileDialog(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "add-context-file");
|
||||||
|
await waitForElement(page, "add-context-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a text context file via the UI
|
||||||
|
*/
|
||||||
|
export async function createContextFile(
|
||||||
|
page: Page,
|
||||||
|
filename: string,
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
await openAddContextFileDialog(page);
|
||||||
|
await clickElement(page, "add-text-type");
|
||||||
|
await fillInput(page, "new-file-name", filename);
|
||||||
|
await fillInput(page, "new-file-content", content);
|
||||||
|
await clickElement(page, "confirm-add-file");
|
||||||
|
await waitForElementHidden(page, "add-context-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an image context file via the UI
|
||||||
|
*/
|
||||||
|
export async function createContextImage(
|
||||||
|
page: Page,
|
||||||
|
filename: string,
|
||||||
|
imagePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
await openAddContextFileDialog(page);
|
||||||
|
await clickElement(page, "add-image-type");
|
||||||
|
await fillInput(page, "new-file-name", filename);
|
||||||
|
await page.setInputFiles('[data-testid="image-upload-input"]', imagePath);
|
||||||
|
await clickElement(page, "confirm-add-file");
|
||||||
|
await waitForElementHidden(page, "add-context-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a context file via the UI (must be selected first)
|
||||||
|
*/
|
||||||
|
export async function deleteSelectedContextFile(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "delete-context-file");
|
||||||
|
await waitForElement(page, "delete-context-dialog");
|
||||||
|
await clickElement(page, "confirm-delete-file");
|
||||||
|
await waitForElementHidden(page, "delete-context-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current context file
|
||||||
|
*/
|
||||||
|
export async function saveContextFile(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "save-context-file");
|
||||||
|
// Wait for save to complete (button shows "Saved")
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-testid="save-context-file"]')
|
||||||
|
?.textContent?.includes("Saved"),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle markdown preview mode
|
||||||
|
*/
|
||||||
|
export async function toggleContextPreviewMode(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "toggle-preview-mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a specific file to appear in the context file list
|
||||||
|
*/
|
||||||
|
export async function waitForContextFile(
|
||||||
|
page: Page,
|
||||||
|
filename: string,
|
||||||
|
timeout: number = 10000
|
||||||
|
): Promise<void> {
|
||||||
|
const locator = await getByTestId(page, `context-file-${filename}`);
|
||||||
|
await locator.waitFor({ state: "visible", timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a file in the list and wait for it to be selected (toolbar visible)
|
||||||
|
* Uses JavaScript click to ensure React event handler fires
|
||||||
|
*/
|
||||||
|
export async function selectContextFile(
|
||||||
|
page: Page,
|
||||||
|
filename: string,
|
||||||
|
timeout: number = 10000
|
||||||
|
): Promise<void> {
|
||||||
|
const fileButton = await getByTestId(page, `context-file-${filename}`);
|
||||||
|
await fileButton.waitFor({ state: "visible", timeout });
|
||||||
|
|
||||||
|
// Use JavaScript click to ensure React onClick handler fires
|
||||||
|
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
|
||||||
|
|
||||||
|
// Wait for the file to be selected (toolbar with delete button becomes visible)
|
||||||
|
const deleteButton = await getByTestId(page, "delete-context-file");
|
||||||
|
await expect(deleteButton).toBeVisible({
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for file content panel to load (either editor, preview, or image)
|
||||||
|
*/
|
||||||
|
export async function waitForFileContentToLoad(page: Page): Promise<void> {
|
||||||
|
// Wait for either the editor, preview, or image to appear
|
||||||
|
await page.waitForSelector(
|
||||||
|
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]',
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch from preview mode to edit mode for markdown files
|
||||||
|
* Markdown files open in preview mode by default, this helper switches to edit mode
|
||||||
|
*/
|
||||||
|
export async function switchToEditMode(page: Page): Promise<void> {
|
||||||
|
// First wait for content to load
|
||||||
|
await waitForFileContentToLoad(page);
|
||||||
|
|
||||||
|
const markdownPreview = await getByTestId(page, "markdown-preview");
|
||||||
|
const isPreview = await markdownPreview.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (isPreview) {
|
||||||
|
await clickElement(page, "toggle-preview-mode");
|
||||||
|
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/app/tests/utils/views/settings.ts
Normal file
8
apps/app/tests/utils/views/settings.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the settings view scrollable content area
|
||||||
|
*/
|
||||||
|
export async function getSettingsContentArea(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="settings-view"] .overflow-y-auto');
|
||||||
|
}
|
||||||
75
apps/app/tests/utils/views/setup.ts
Normal file
75
apps/app/tests/utils/views/setup.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { getByTestId } from "../core/elements";
|
||||||
|
import { waitForElement } from "../core/waiting";
|
||||||
|
import { setupFirstRun } from "../project/setup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for setup view to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForSetupView(page: Page): Promise<Locator> {
|
||||||
|
return waitForElement(page, "setup-view", { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click "Get Started" button on setup welcome step
|
||||||
|
*/
|
||||||
|
export async function clickSetupGetStarted(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "setup-start-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click continue on Claude setup step
|
||||||
|
*/
|
||||||
|
export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "claude-next-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click finish on setup complete step
|
||||||
|
*/
|
||||||
|
export async function clickSetupFinish(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "setup-finish-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter Anthropic API key in setup
|
||||||
|
*/
|
||||||
|
export async function enterAnthropicApiKey(
|
||||||
|
page: Page,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Click "Use Anthropic API Key Instead" button
|
||||||
|
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
|
||||||
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
|
// Enter the API key
|
||||||
|
const input = await getByTestId(page, "anthropic-api-key-input");
|
||||||
|
await input.fill(apiKey);
|
||||||
|
|
||||||
|
// Click save button
|
||||||
|
const saveButton = await getByTestId(page, "save-anthropic-key-button");
|
||||||
|
await saveButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter OpenAI API key in setup
|
||||||
|
*/
|
||||||
|
export async function enterOpenAIApiKey(
|
||||||
|
page: Page,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Click "Enter OpenAI API Key" button
|
||||||
|
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
|
||||||
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
|
// Enter the API key
|
||||||
|
const input = await getByTestId(page, "openai-api-key-input");
|
||||||
|
await input.fill(apiKey);
|
||||||
|
|
||||||
|
// Click save button
|
||||||
|
const saveButton = await getByTestId(page, "save-openai-key-button");
|
||||||
|
await saveButton.click();
|
||||||
|
}
|
||||||
118
apps/app/tests/utils/views/spec-editor.ts
Normal file
118
apps/app/tests/utils/views/spec-editor.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { clickElement } from "../core/interactions";
|
||||||
|
import { navigateToSpec } from "../navigation/views";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the spec editor element
|
||||||
|
*/
|
||||||
|
export async function getSpecEditor(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="spec-editor"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the spec editor content
|
||||||
|
*/
|
||||||
|
export async function getSpecEditorContent(page: Page): Promise<string> {
|
||||||
|
const editor = await getSpecEditor(page);
|
||||||
|
return await editor.inputValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the spec editor content
|
||||||
|
*/
|
||||||
|
export async function setSpecEditorContent(
|
||||||
|
page: Page,
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
const editor = await getSpecEditor(page);
|
||||||
|
await editor.fill(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the save spec button
|
||||||
|
*/
|
||||||
|
export async function clickSaveSpec(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "save-spec");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the reload spec button
|
||||||
|
*/
|
||||||
|
export async function clickReloadSpec(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "reload-spec");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the spec view path display shows the correct .automaker path
|
||||||
|
*/
|
||||||
|
export async function getDisplayedSpecPath(page: Page): Promise<string | null> {
|
||||||
|
const specView = page.locator('[data-testid="spec-view"]');
|
||||||
|
const pathElement = specView.locator("p.text-muted-foreground").first();
|
||||||
|
return await pathElement.textContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the spec editor view
|
||||||
|
*/
|
||||||
|
export async function navigateToSpecEditor(page: Page): Promise<void> {
|
||||||
|
await navigateToSpec(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CodeMirror editor content
|
||||||
|
*/
|
||||||
|
export async function getEditorContent(page: Page): Promise<string> {
|
||||||
|
// CodeMirror uses a contenteditable div with class .cm-content
|
||||||
|
const content = await page
|
||||||
|
.locator('[data-testid="spec-editor"] .cm-content')
|
||||||
|
.textContent();
|
||||||
|
return content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the CodeMirror editor content by selecting all and typing
|
||||||
|
*/
|
||||||
|
export async function setEditorContent(page: Page, content: string): Promise<void> {
|
||||||
|
// Click on the editor to focus it
|
||||||
|
const editor = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||||
|
await editor.click();
|
||||||
|
|
||||||
|
// Wait for focus
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Select all content (Cmd+A on Mac, Ctrl+A on others)
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||||
|
|
||||||
|
// Wait for selection
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Delete the selected content first
|
||||||
|
await page.keyboard.press("Backspace");
|
||||||
|
|
||||||
|
// Wait for deletion
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Type the new content
|
||||||
|
await page.keyboard.type(content, { delay: 10 });
|
||||||
|
|
||||||
|
// Wait for typing to complete
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the save button
|
||||||
|
*/
|
||||||
|
export async function clickSaveButton(page: Page): Promise<void> {
|
||||||
|
const saveButton = page.locator('[data-testid="save-spec"]');
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Wait for the button text to change to "Saved" indicating save is complete
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const btn = document.querySelector('[data-testid="save-spec"]');
|
||||||
|
return btn?.textContent?.includes("Saved");
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user