mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge remote-tracking branch 'origin/main' into category
This commit is contained in:
272
apps/ui/tests/utils/api/client.ts
Normal file
272
apps/ui/tests/utils/api/client.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* API client utilities for making API calls in tests
|
||||
* Provides type-safe wrappers around common API operations
|
||||
*/
|
||||
|
||||
import { Page, APIResponse } from "@playwright/test";
|
||||
import { API_ENDPOINTS } from "../core/constants";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isNew?: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
success: boolean;
|
||||
worktrees: WorktreeInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeCreateResponse {
|
||||
success: boolean;
|
||||
worktree?: WorktreeInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeDeleteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
committed: boolean;
|
||||
branch?: string;
|
||||
commitHash?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommitResponse {
|
||||
success: boolean;
|
||||
result?: CommitResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SwitchBranchResult {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SwitchBranchResponse {
|
||||
success: boolean;
|
||||
result?: SwitchBranchResult;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ListBranchesResult {
|
||||
currentBranch: string;
|
||||
branches: BranchInfo[];
|
||||
}
|
||||
|
||||
export interface ListBranchesResponse {
|
||||
success: boolean;
|
||||
result?: ListBranchesResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree API Client
|
||||
// ============================================================================
|
||||
|
||||
export class WorktreeApiClient {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Create a new worktree
|
||||
*/
|
||||
async create(
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.create, {
|
||||
data: {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a worktree
|
||||
*/
|
||||
async delete(
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.delete, {
|
||||
data: {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees
|
||||
*/
|
||||
async list(
|
||||
projectPath: string,
|
||||
includeDetails: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.list, {
|
||||
data: {
|
||||
projectPath,
|
||||
includeDetails,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes in a worktree
|
||||
*/
|
||||
async commit(
|
||||
worktreePath: string,
|
||||
message: string
|
||||
): Promise<{ response: APIResponse; data: CommitResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.commit, {
|
||||
data: {
|
||||
worktreePath,
|
||||
message,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch branches in a worktree
|
||||
*/
|
||||
async switchBranch(
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.switchBranch, {
|
||||
data: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all branches
|
||||
*/
|
||||
async listBranches(
|
||||
worktreePath: string
|
||||
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
|
||||
const response = await this.page.request.post(API_ENDPOINTS.worktree.listBranches, {
|
||||
data: {
|
||||
worktreePath,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return { response, data };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a WorktreeApiClient instance
|
||||
*/
|
||||
export function createWorktreeApiClient(page: Page): WorktreeApiClient {
|
||||
return new WorktreeApiClient(page);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions (for direct use without creating a client)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a worktree via API
|
||||
*/
|
||||
export async function apiCreateWorktree(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
|
||||
return new WorktreeApiClient(page).create(projectPath, branchName, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a worktree via API
|
||||
*/
|
||||
export async function apiDeleteWorktree(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
|
||||
return new WorktreeApiClient(page).delete(projectPath, worktreePath, deleteBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* List worktrees via API
|
||||
*/
|
||||
export async function apiListWorktrees(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
includeDetails: boolean = true
|
||||
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
|
||||
return new WorktreeApiClient(page).list(projectPath, includeDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes in a worktree via API
|
||||
*/
|
||||
export async function apiCommitWorktree(
|
||||
page: Page,
|
||||
worktreePath: string,
|
||||
message: string
|
||||
): Promise<{ response: APIResponse; data: CommitResponse }> {
|
||||
return new WorktreeApiClient(page).commit(worktreePath, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch branches in a worktree via API
|
||||
*/
|
||||
export async function apiSwitchBranch(
|
||||
page: Page,
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
|
||||
return new WorktreeApiClient(page).switchBranch(worktreePath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches via API
|
||||
*/
|
||||
export async function apiListBranches(
|
||||
page: Page,
|
||||
worktreePath: string
|
||||
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
|
||||
return new WorktreeApiClient(page).listBranches(worktreePath);
|
||||
}
|
||||
78
apps/ui/tests/utils/components/autocomplete.ts
Normal file
78
apps/ui/tests/utils/components/autocomplete.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Create new" option for a category that doesn't exist
|
||||
*/
|
||||
export async function clickCreateNewCategoryOption(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
const option = page.locator('[data-testid="category-option-create-new"]');
|
||||
await option.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Create new" option element for categories
|
||||
*/
|
||||
export async function getCreateNewCategoryOption(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="category-option-create-new"]');
|
||||
}
|
||||
200
apps/ui/tests/utils/components/dialogs.ts
Normal file
200
apps/ui/tests/utils/components/dialogs.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Check if the add feature dialog is visible
|
||||
*/
|
||||
export async function isAddFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-feature-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the add context file dialog is visible
|
||||
*/
|
||||
export async function isAddContextDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="add-context-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit feature dialog is visible
|
||||
*/
|
||||
export async function isEditFeatureDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="edit-feature-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the edit feature dialog to be visible
|
||||
*/
|
||||
export async function waitForEditFeatureDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "edit-feature-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the edit feature description input/textarea element
|
||||
*/
|
||||
export async function getEditFeatureDescriptionInput(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="edit-feature-description"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit feature description field is a textarea
|
||||
*/
|
||||
export async function isEditFeatureDescriptionTextarea(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const element = page.locator('[data-testid="edit-feature-description"]');
|
||||
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
|
||||
return tagName === "textarea";
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a specific feature
|
||||
*/
|
||||
export async function openEditFeatureDialog(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `edit-feature-${featureId}`);
|
||||
await waitForEditFeatureDialog(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the edit feature description field
|
||||
*/
|
||||
export async function fillEditFeatureDescription(
|
||||
page: Page,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const input = await getEditFeatureDescriptionInput(page);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the confirm edit feature button
|
||||
*/
|
||||
export async function confirmEditFeature(page: Page): Promise<void> {
|
||||
await clickElement(page, "confirm-edit-feature");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the delete confirmation dialog
|
||||
*/
|
||||
export async function getDeleteConfirmationDialog(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the delete confirmation dialog is visible
|
||||
*/
|
||||
export async function isDeleteConfirmationDialogVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the delete confirmation dialog to appear
|
||||
*/
|
||||
export async function waitForDeleteConfirmationDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "delete-confirmation-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the delete confirmation dialog to be hidden
|
||||
*/
|
||||
export async function waitForDeleteConfirmationDialogHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await waitForElementHidden(page, "delete-confirmation-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the confirm delete button in the delete confirmation dialog
|
||||
*/
|
||||
export async function clickConfirmDeleteButton(page: Page): Promise<void> {
|
||||
await clickElement(page, "confirm-delete-button");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the cancel delete button in the delete confirmation dialog
|
||||
*/
|
||||
export async function clickCancelDeleteButton(page: Page): Promise<void> {
|
||||
await clickElement(page, "cancel-delete-button");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the follow-up dialog is visible
|
||||
*/
|
||||
export async function isFollowUpDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="follow-up-dialog"]');
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the follow-up dialog to be visible
|
||||
*/
|
||||
export async function waitForFollowUpDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "follow-up-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the follow-up dialog to be hidden
|
||||
*/
|
||||
export async function waitForFollowUpDialogHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await waitForElementHidden(page, "follow-up-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the confirm follow-up button in the follow-up dialog
|
||||
*/
|
||||
export async function clickConfirmFollowUp(page: Page): Promise<void> {
|
||||
await clickElement(page, "confirm-follow-up");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project initialization dialog is visible
|
||||
*/
|
||||
export async function isProjectInitDialogVisible(page: Page): Promise<boolean> {
|
||||
const dialog = page.locator('[data-testid="project-init-dialog"]');
|
||||
return await dialog.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the project initialization dialog to appear
|
||||
*/
|
||||
export async function waitForProjectInitDialog(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "project-init-dialog", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the project initialization dialog
|
||||
*/
|
||||
export async function closeProjectInitDialog(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('[data-testid="close-init-dialog"]');
|
||||
await closeButton.click();
|
||||
}
|
||||
104
apps/ui/tests/utils/components/modals.ts
Normal file
104
apps/ui/tests/utils/components/modals.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Check if the agent output modal is visible
|
||||
*/
|
||||
export async function isAgentOutputModalVisible(page: Page): Promise<boolean> {
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
return await modal.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent output modal to be visible
|
||||
*/
|
||||
export async function waitForAgentOutputModal(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "agent-output-modal", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent output modal to be hidden
|
||||
*/
|
||||
export async function waitForAgentOutputModalHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await waitForElementHidden(page, "agent-output-modal", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modal title/description text to verify which feature's output is being shown
|
||||
*/
|
||||
export async function getAgentOutputModalDescription(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
const description = modal
|
||||
.locator('[id="radix-\\:r.+\\:-description"]')
|
||||
.first();
|
||||
return await description.textContent().catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the dialog description content in the agent output modal
|
||||
*/
|
||||
export async function getOutputModalDescription(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
const modalDescription = page.locator(
|
||||
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
|
||||
);
|
||||
return await modalDescription.textContent().catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent output modal description element
|
||||
*/
|
||||
export async function getAgentOutputModalDescriptionElement(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="agent-output-description"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent output modal description is scrollable
|
||||
*/
|
||||
export async function isAgentOutputDescriptionScrollable(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const description = page.locator('[data-testid="agent-output-description"]');
|
||||
const scrollInfo = await description.evaluate((el) => {
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
isScrollable: el.scrollHeight > el.clientHeight,
|
||||
};
|
||||
});
|
||||
return scrollInfo.isScrollable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scroll dimensions of the agent output modal description
|
||||
*/
|
||||
export async function getAgentOutputDescriptionScrollDimensions(
|
||||
page: Page
|
||||
): Promise<{
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
maxHeight: string;
|
||||
overflowY: string;
|
||||
}> {
|
||||
const description = page.locator('[data-testid="agent-output-description"]');
|
||||
return await description.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
maxHeight: style.maxHeight,
|
||||
overflowY: style.overflowY,
|
||||
};
|
||||
});
|
||||
}
|
||||
87
apps/ui/tests/utils/components/toasts.ts
Normal file
87
apps/ui/tests/utils/components/toasts.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Wait for a toast notification with specific text to appear
|
||||
*/
|
||||
export async function waitForToast(
|
||||
page: Page,
|
||||
text: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an error toast to appear with specific text
|
||||
*/
|
||||
export async function waitForErrorToast(
|
||||
page: Page,
|
||||
titleText?: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
// Try multiple selectors for error toasts since Sonner versions may differ
|
||||
// 1. Try with data-type="error" attribute
|
||||
// 2. Fallback to any toast with the text (error styling might vary)
|
||||
const timeout = options?.timeout ?? 5000;
|
||||
|
||||
if (titleText) {
|
||||
// First try specific error type, then fallback to any toast with text
|
||||
const errorToast = page.locator(
|
||||
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
||||
).first();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
});
|
||||
return errorToast;
|
||||
} else {
|
||||
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
});
|
||||
return errorToast;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error toast is visible
|
||||
*/
|
||||
export async function isErrorToastVisible(
|
||||
page: Page,
|
||||
titleText?: string
|
||||
): Promise<boolean> {
|
||||
const toastSelector = titleText
|
||||
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
|
||||
: '[data-sonner-toast][data-type="error"]';
|
||||
|
||||
const toast = page.locator(toastSelector).first();
|
||||
return await toast.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a success toast to appear with specific text
|
||||
*/
|
||||
export async function waitForSuccessToast(
|
||||
page: Page,
|
||||
titleText?: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
// Sonner toasts use data-sonner-toast and data-type="success" for success toasts
|
||||
const toastSelector = titleText
|
||||
? `[data-sonner-toast][data-type="success"]:has-text("${titleText}")`
|
||||
: '[data-sonner-toast][data-type="success"]';
|
||||
|
||||
const toast = page.locator(toastSelector).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
187
apps/ui/tests/utils/core/constants.ts
Normal file
187
apps/ui/tests/utils/core/constants.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Centralized constants for test utilities
|
||||
* This file contains all shared constants like URLs, timeouts, and selectors
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base URL for the API server
|
||||
*/
|
||||
export const API_BASE_URL = "http://localhost:3008";
|
||||
|
||||
/**
|
||||
* API endpoints for worktree operations
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
worktree: {
|
||||
create: `${API_BASE_URL}/api/worktree/create`,
|
||||
delete: `${API_BASE_URL}/api/worktree/delete`,
|
||||
list: `${API_BASE_URL}/api/worktree/list`,
|
||||
commit: `${API_BASE_URL}/api/worktree/commit`,
|
||||
switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`,
|
||||
listBranches: `${API_BASE_URL}/api/worktree/list-branches`,
|
||||
status: `${API_BASE_URL}/api/worktree/status`,
|
||||
info: `${API_BASE_URL}/api/worktree/info`,
|
||||
},
|
||||
fs: {
|
||||
browse: `${API_BASE_URL}/api/fs/browse`,
|
||||
read: `${API_BASE_URL}/api/fs/read`,
|
||||
write: `${API_BASE_URL}/api/fs/write`,
|
||||
},
|
||||
features: {
|
||||
list: `${API_BASE_URL}/api/features/list`,
|
||||
create: `${API_BASE_URL}/api/features/create`,
|
||||
update: `${API_BASE_URL}/api/features/update`,
|
||||
delete: `${API_BASE_URL}/api/features/delete`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Timeout Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default timeouts in milliseconds
|
||||
*/
|
||||
export const TIMEOUTS = {
|
||||
/** Default timeout for element visibility checks */
|
||||
default: 5000,
|
||||
/** Short timeout for quick checks */
|
||||
short: 2000,
|
||||
/** Medium timeout for standard operations */
|
||||
medium: 10000,
|
||||
/** Long timeout for slow operations */
|
||||
long: 30000,
|
||||
/** Extra long timeout for very slow operations */
|
||||
extraLong: 60000,
|
||||
/** Timeout for animations to complete */
|
||||
animation: 300,
|
||||
/** Small delay for UI to settle */
|
||||
settle: 500,
|
||||
/** Delay for network operations */
|
||||
network: 1000,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Test ID Selectors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common data-testid selectors organized by component/view
|
||||
*/
|
||||
export const TEST_IDS = {
|
||||
// Sidebar & Navigation
|
||||
sidebar: "sidebar",
|
||||
navBoard: "nav-board",
|
||||
navSpec: "nav-spec",
|
||||
navContext: "nav-context",
|
||||
navAgent: "nav-agent",
|
||||
navProfiles: "nav-profiles",
|
||||
settingsButton: "settings-button",
|
||||
openProjectButton: "open-project-button",
|
||||
|
||||
// Views
|
||||
boardView: "board-view",
|
||||
specView: "spec-view",
|
||||
contextView: "context-view",
|
||||
agentView: "agent-view",
|
||||
profilesView: "profiles-view",
|
||||
settingsView: "settings-view",
|
||||
welcomeView: "welcome-view",
|
||||
setupView: "setup-view",
|
||||
|
||||
// Board View Components
|
||||
addFeatureButton: "add-feature-button",
|
||||
addFeatureDialog: "add-feature-dialog",
|
||||
confirmAddFeature: "confirm-add-feature",
|
||||
featureBranchInput: "feature-input",
|
||||
featureCategoryInput: "feature-category-input",
|
||||
worktreeSelector: "worktree-selector",
|
||||
|
||||
// Spec Editor
|
||||
specEditor: "spec-editor",
|
||||
|
||||
// File Browser Dialog
|
||||
pathInput: "path-input",
|
||||
goToPathButton: "go-to-path-button",
|
||||
|
||||
// Profiles View
|
||||
addProfileButton: "add-profile-button",
|
||||
addProfileDialog: "add-profile-dialog",
|
||||
editProfileDialog: "edit-profile-dialog",
|
||||
deleteProfileConfirmDialog: "delete-profile-confirm-dialog",
|
||||
saveProfileButton: "save-profile-button",
|
||||
confirmDeleteProfileButton: "confirm-delete-profile-button",
|
||||
cancelDeleteButton: "cancel-delete-button",
|
||||
profileNameInput: "profile-name-input",
|
||||
profileDescriptionInput: "profile-description-input",
|
||||
refreshProfilesButton: "refresh-profiles-button",
|
||||
|
||||
// Context View
|
||||
contextFileList: "context-file-list",
|
||||
addContextButton: "add-context-button",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// CSS Selectors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common CSS selectors for elements that don't have data-testid
|
||||
*/
|
||||
export const CSS_SELECTORS = {
|
||||
/** CodeMirror editor content area */
|
||||
codeMirrorContent: ".cm-content",
|
||||
/** Dialog elements */
|
||||
dialog: '[role="dialog"]',
|
||||
/** Sonner toast notifications */
|
||||
toast: "[data-sonner-toast]",
|
||||
toastError: '[data-sonner-toast][data-type="error"]',
|
||||
toastSuccess: '[data-sonner-toast][data-type="success"]',
|
||||
/** Command/combobox input (shadcn-ui cmdk) */
|
||||
commandInput: "[cmdk-input]",
|
||||
/** Radix dialog overlay */
|
||||
dialogOverlay: "[data-radix-dialog-overlay]",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* localStorage keys used by the application
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
appStorage: "automaker-storage",
|
||||
setupStorage: "automaker-setup",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Branch Name Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sanitize a branch name to create a valid worktree directory name
|
||||
* @param branchName - The branch name to sanitize
|
||||
* @returns Sanitized name suitable for directory paths
|
||||
*/
|
||||
export function sanitizeBranchName(branchName: string): string {
|
||||
return branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default values used in test setup
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
projectName: "Test Project",
|
||||
projectPath: "/mock/test-project",
|
||||
theme: "dark" as const,
|
||||
maxConcurrency: 3,
|
||||
} as const;
|
||||
40
apps/ui/tests/utils/core/elements.ts
Normal file
40
apps/ui/tests/utils/core/elements.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get an element by its data-testid attribute
|
||||
*/
|
||||
export async function getByTestId(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a button by its text content
|
||||
*/
|
||||
export async function getButtonByText(
|
||||
page: Page,
|
||||
text: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`button:has-text("${text}")`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category autocomplete input element
|
||||
*/
|
||||
export async function getCategoryAutocompleteInput(
|
||||
page: Page,
|
||||
testId: string = "feature-category-input"
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category autocomplete dropdown list
|
||||
*/
|
||||
export async function getCategoryAutocompleteList(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="category-autocomplete-list"]');
|
||||
}
|
||||
86
apps/ui/tests/utils/core/interactions.ts
Normal file
86
apps/ui/tests/utils/core/interactions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { getByTestId, getButtonByText } from "./elements";
|
||||
|
||||
/**
|
||||
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
|
||||
* This is used for keyboard shortcuts like Cmd+Enter or Ctrl+Enter
|
||||
*/
|
||||
export function getPlatformModifier(): "Meta" | "Control" {
|
||||
return process.platform === "darwin" ? "Meta" : "Control";
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the platform-specific modifier + a key (e.g., Cmd+Enter or Ctrl+Enter)
|
||||
*/
|
||||
export async function pressModifierEnter(page: Page): Promise<void> {
|
||||
const modifier = getPlatformModifier();
|
||||
await page.keyboard.press(`${modifier}+Enter`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element by its data-testid attribute
|
||||
*/
|
||||
export async function clickElement(page: Page, testId: string): Promise<void> {
|
||||
const element = await getByTestId(page, testId);
|
||||
await element.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a button by its text content
|
||||
*/
|
||||
export async function clickButtonByText(
|
||||
page: Page,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
const button = await getButtonByText(page, text);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input field by its data-testid attribute
|
||||
*/
|
||||
export async function fillInput(
|
||||
page: Page,
|
||||
testId: string,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const input = await getByTestId(page, testId);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a keyboard shortcut key
|
||||
*/
|
||||
export async function pressShortcut(page: Page, key: string): Promise<void> {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a number key (0-9) on the keyboard
|
||||
*/
|
||||
export async function pressNumberKey(page: Page, num: number): Promise<void> {
|
||||
await page.keyboard.press(num.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an input element to test that shortcuts don't fire when typing
|
||||
*/
|
||||
export async function focusOnInput(page: Page, testId: string): Promise<void> {
|
||||
const input = page.locator(`[data-testid="${testId}"]`);
|
||||
await input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open dialog by pressing Escape
|
||||
* Waits for dialog to be removed from DOM rather than using arbitrary timeout
|
||||
*/
|
||||
export async function closeDialogWithEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
// Wait for any dialog overlay to disappear
|
||||
await page
|
||||
.locator('[data-radix-dialog-overlay], [role="dialog"]')
|
||||
.waitFor({ state: "hidden", timeout: 5000 })
|
||||
.catch(() => {
|
||||
// Dialog may have already closed or not exist
|
||||
});
|
||||
}
|
||||
40
apps/ui/tests/utils/core/waiting.ts
Normal file
40
apps/ui/tests/utils/core/waiting.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Wait for the page to reach network idle state
|
||||
* This is commonly used after navigation or page reload to ensure all network requests have completed
|
||||
*/
|
||||
export async function waitForNetworkIdle(page: Page): Promise<void> {
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with a specific data-testid to appear
|
||||
*/
|
||||
export async function waitForElement(
|
||||
page: Page,
|
||||
testId: string,
|
||||
options?: { timeout?: number; state?: "attached" | "visible" | "hidden" }
|
||||
): Promise<Locator> {
|
||||
const element = page.locator(`[data-testid="${testId}"]`);
|
||||
await element.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: options?.state ?? "visible",
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with a specific data-testid to be hidden
|
||||
*/
|
||||
export async function waitForElementHidden(
|
||||
page: Page,
|
||||
testId: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const element = page.locator(`[data-testid="${testId}"]`);
|
||||
await element.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "hidden",
|
||||
});
|
||||
}
|
||||
62
apps/ui/tests/utils/features/kanban.ts
Normal file
62
apps/ui/tests/utils/features/kanban.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Perform a drag and drop operation that works with @dnd-kit
|
||||
* This uses explicit mouse movements with pointer events
|
||||
*
|
||||
* NOTE: dnd-kit requires careful timing for drag activation. In CI environments,
|
||||
* we need longer delays and more movement steps for reliable detection.
|
||||
*/
|
||||
export async function dragAndDropWithDndKit(
|
||||
page: Page,
|
||||
sourceLocator: Locator,
|
||||
targetLocator: Locator
|
||||
): Promise<void> {
|
||||
// Ensure elements are visible and stable before getting bounding boxes
|
||||
await sourceLocator.waitFor({ state: "visible", timeout: 5000 });
|
||||
await targetLocator.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Small delay to ensure layout is stable
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const sourceBox = await sourceLocator.boundingBox();
|
||||
const targetBox = await targetLocator.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error("Could not find source or target element bounds");
|
||||
}
|
||||
|
||||
// Start drag from the center of the source element
|
||||
const startX = sourceBox.x + sourceBox.width / 2;
|
||||
const startY = sourceBox.y + sourceBox.height / 2;
|
||||
|
||||
// End drag at the center of the target element
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Move to source element first
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
// Press and hold - dnd-kit needs time to activate the drag sensor
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(300); // Longer delay for CI - dnd-kit activation threshold
|
||||
|
||||
// Move slightly first to trigger drag detection (dnd-kit has a distance threshold)
|
||||
const smallMoveX = startX + 10;
|
||||
const smallMoveY = startY + 10;
|
||||
await page.mouse.move(smallMoveX, smallMoveY, { steps: 3 });
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Now move to target with slower, more deliberate movement
|
||||
await page.mouse.move(endX, endY, { steps: 25 });
|
||||
|
||||
// Pause over target for drop detection
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Release
|
||||
await page.mouse.up();
|
||||
|
||||
// Allow time for the drop handler to process
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
114
apps/ui/tests/utils/features/skip-tests.ts
Normal file
114
apps/ui/tests/utils/features/skip-tests.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the skip tests checkbox element in the add feature dialog
|
||||
*/
|
||||
export async function getSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="skip-tests-checkbox"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the skip tests checkbox in the add feature dialog
|
||||
*/
|
||||
export async function toggleSkipTestsCheckbox(page: Page): Promise<void> {
|
||||
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
|
||||
await checkbox.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests checkbox is checked in the add feature dialog
|
||||
*/
|
||||
export async function isSkipTestsChecked(page: Page): Promise<boolean> {
|
||||
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
|
||||
const state = await checkbox.getAttribute("data-state");
|
||||
return state === "checked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the edit skip tests checkbox element in the edit feature dialog
|
||||
*/
|
||||
export async function getEditSkipTestsCheckbox(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the skip tests checkbox in the edit feature dialog
|
||||
*/
|
||||
export async function toggleEditSkipTestsCheckbox(page: Page): Promise<void> {
|
||||
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||
await checkbox.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests checkbox is checked in the edit feature dialog
|
||||
*/
|
||||
export async function isEditSkipTestsChecked(page: Page): Promise<boolean> {
|
||||
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
|
||||
const state = await checkbox.getAttribute("data-state");
|
||||
return state === "checked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the skip tests badge is visible on a kanban card
|
||||
*/
|
||||
export async function isSkipTestsBadgeVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const badge = page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the skip tests badge element for a kanban card
|
||||
*/
|
||||
export async function getSkipTestsBadge(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the manual verify button for a skipTests feature
|
||||
*/
|
||||
export async function clickManualVerify(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the manual verify button is visible for a feature
|
||||
*/
|
||||
export async function isManualVerifyButtonVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
|
||||
return await button.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the move back button for a verified skipTests feature
|
||||
*/
|
||||
export async function clickMoveBack(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the move back button is visible for a feature
|
||||
*/
|
||||
export async function isMoveBackButtonVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
|
||||
return await button.isVisible().catch(() => false);
|
||||
}
|
||||
36
apps/ui/tests/utils/features/timers.ts
Normal file
36
apps/ui/tests/utils/features/timers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the count up timer element for a specific feature card
|
||||
*/
|
||||
export async function getTimerForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
return card.locator('[data-testid="count-up-timer"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timer display text for a specific feature card
|
||||
*/
|
||||
export async function getTimerDisplayForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const timerDisplay = card.locator('[data-testid="timer-display"]');
|
||||
return await timerDisplay.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timer is visible for a specific feature
|
||||
*/
|
||||
export async function isTimerVisibleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const timer = card.locator('[data-testid="count-up-timer"]');
|
||||
return await timer.isVisible().catch(() => false);
|
||||
}
|
||||
82
apps/ui/tests/utils/features/waiting-approval.ts
Normal file
82
apps/ui/tests/utils/features/waiting-approval.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the follow-up button for a waiting_approval feature
|
||||
*/
|
||||
export async function getFollowUpButton(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the follow-up button for a waiting_approval feature
|
||||
*/
|
||||
export async function clickFollowUpButton(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the follow-up button is visible for a feature
|
||||
*/
|
||||
export async function isFollowUpButtonVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
|
||||
return await button.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the commit button for a waiting_approval feature
|
||||
*/
|
||||
export async function getCommitButton(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the commit button for a waiting_approval feature
|
||||
*/
|
||||
export async function clickCommitButton(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the commit button is visible for a feature
|
||||
*/
|
||||
export async function isCommitButtonVisible(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
return await button.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the waiting_approval kanban column
|
||||
*/
|
||||
export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the waiting_approval column is visible
|
||||
*/
|
||||
export async function isWaitingApprovalColumnVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
return await column.isVisible().catch(() => false);
|
||||
}
|
||||
82
apps/ui/tests/utils/files/drag-drop.ts
Normal file
82
apps/ui/tests/utils/files/drag-drop.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Simulate drag and drop of a file onto an element
|
||||
*/
|
||||
export async function simulateFileDrop(
|
||||
page: Page,
|
||||
targetSelector: string,
|
||||
fileName: string,
|
||||
fileContent: string,
|
||||
mimeType: string = "text/plain"
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ selector, content, name, mime }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
const file = new File([content], name, { type: mime });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Dispatch drag events
|
||||
target.dispatchEvent(
|
||||
new DragEvent("dragover", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate pasting an image from clipboard onto an element
|
||||
* Works across all OS (Windows, Linux, macOS)
|
||||
*/
|
||||
export async function simulateImagePaste(
|
||||
page: Page,
|
||||
targetSelector: string,
|
||||
imageBase64: string,
|
||||
mimeType: string = "image/png"
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ selector, base64, mime }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
// Convert base64 to Blob
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mime });
|
||||
|
||||
// Create a File from Blob
|
||||
const file = new File([blob], "pasted-image.png", { type: mime });
|
||||
|
||||
// Create a DataTransfer with clipboard items
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Create ClipboardEvent with the image data
|
||||
const clipboardEvent = new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dataTransfer,
|
||||
});
|
||||
|
||||
target.dispatchEvent(clipboardEvent);
|
||||
},
|
||||
{ selector: targetSelector, base64: imageBase64, mime: mimeType }
|
||||
);
|
||||
}
|
||||
497
apps/ui/tests/utils/git/worktree.ts
Normal file
497
apps/ui/tests/utils/git/worktree.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Git worktree utilities for testing
|
||||
* Provides helpers for creating test git repos and managing worktrees
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { Page } from "@playwright/test";
|
||||
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FeatureData {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: string;
|
||||
branchName?: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the workspace root directory (internal use only)
|
||||
* Note: Also exported from project/fixtures.ts for broader use
|
||||
*/
|
||||
function getWorkspaceRoot(): string {
|
||||
const cwd = process.cwd();
|
||||
if (cwd.includes("apps/ui")) {
|
||||
return path.resolve(cwd, "../..");
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique temp directory path for tests
|
||||
*/
|
||||
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
|
||||
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected worktree path for a branch
|
||||
*/
|
||||
export function getWorktreePath(projectPath: string, branchName: string): string {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
return path.join(projectPath, ".worktrees", sanitizedName);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Repository Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a temporary git repository for testing
|
||||
*/
|
||||
export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
||||
// Create temp directory if it doesn't exist
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync("git init", { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Create initial commit
|
||||
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
|
||||
await execAsync("git add .", { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync("git branch -M main", { cwd: tmpDir });
|
||||
|
||||
// Create .automaker directories
|
||||
const automakerDir = path.join(tmpDir, ".automaker");
|
||||
const featuresDir = path.join(automakerDir, "features");
|
||||
fs.mkdirSync(featuresDir, { recursive: true });
|
||||
|
||||
// Create empty categories.json to avoid ENOENT errors in tests
|
||||
fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]");
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
await cleanupTestRepo(tmpDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a test git repository
|
||||
*/
|
||||
export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
||||
try {
|
||||
// Remove all worktrees first
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoPath,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
|
||||
const worktrees = stdout
|
||||
.split("\n\n")
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const worktreePath of worktrees) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: repoPath,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the repository
|
||||
fs.rmSync(repoPath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup test repo:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a temp directory and all its contents
|
||||
*/
|
||||
export function cleanupTempDir(tempDir: string): void {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a git command in a repository
|
||||
*/
|
||||
export async function gitExec(
|
||||
repoPath: string,
|
||||
command: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return execAsync(`git ${command}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git worktrees
|
||||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split("\n\n")
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
if (!pathLine) return null;
|
||||
// Normalize path separators to OS native (git on Windows returns forward slashes)
|
||||
const worktreePath = pathLine.replace("worktree ", "");
|
||||
return path.normalize(worktreePath);
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of git branches
|
||||
*/
|
||||
export async function listBranches(repoPath: string): Promise<string[]> {
|
||||
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current branch name
|
||||
*/
|
||||
export async function getCurrentBranch(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git branch
|
||||
*/
|
||||
export async function createBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execAsync(`git branch ${branchName}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a git branch
|
||||
*/
|
||||
export async function checkoutBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execAsync(`git checkout ${branchName}`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git worktree using git command directly
|
||||
*/
|
||||
export async function createWorktreeDirectly(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
worktreePath?: string
|
||||
): Promise<string> {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
|
||||
|
||||
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add and commit a file
|
||||
*/
|
||||
export async function commitFile(
|
||||
repoPath: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
fs.writeFileSync(path.join(repoPath, filePath), content);
|
||||
await execAsync(`git add "${filePath}"`, { cwd: repoPath });
|
||||
await execAsync(`git commit -m "${message}"`, { cwd: repoPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest commit message
|
||||
*/
|
||||
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature File Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a feature file in the test repo
|
||||
*/
|
||||
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
|
||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||
const featureDir = path.join(featuresDir, featureId);
|
||||
|
||||
fs.mkdirSync(featureDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a feature file from the test repo
|
||||
*/
|
||||
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
|
||||
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
|
||||
|
||||
if (!fs.existsSync(featureFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all feature directories in the test repo
|
||||
*/
|
||||
export function listTestFeatures(repoPath: string): string[] {
|
||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||
|
||||
if (!fs.existsSync(featuresDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(featuresDir);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Setup for Tests
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to a test repo
|
||||
*/
|
||||
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-worktree",
|
||||
name: "Worktree Test Project",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: true, // Enable worktree feature for tests
|
||||
currentWorktreeByProject: {
|
||||
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
|
||||
* Use this to test scenarios where the worktree feature flag is off
|
||||
*/
|
||||
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-no-worktree",
|
||||
name: "Test Project (No Worktrees)",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: false, // Worktree feature DISABLED
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project that has STALE worktree data
|
||||
* The currentWorktreeByProject points to a worktree path that no longer exists
|
||||
* This simulates the scenario where a user previously selected a worktree that was later deleted
|
||||
*/
|
||||
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-stale-worktree",
|
||||
name: "Stale Worktree Test Project",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: true, // Enable worktree feature for tests
|
||||
currentWorktreeByProject: {
|
||||
// This is STALE data - pointing to a worktree path that doesn't exist
|
||||
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wait Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wait for the board view to load
|
||||
* Navigates to /board first since the index route shows WelcomeView
|
||||
* Handles zustand store hydration timing (may show "no-project" briefly)
|
||||
*/
|
||||
export async function waitForBoardView(page: Page): Promise<void> {
|
||||
// Navigate directly to /board route (index route shows welcome view)
|
||||
const currentUrl = page.url();
|
||||
if (!currentUrl.includes('/board')) {
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Wait for either board-view (success) or board-view-no-project (store not hydrated yet)
|
||||
// Then poll until board-view appears (zustand hydrates from localStorage)
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
const noProject = document.querySelector('[data-testid="board-view-no-project"]');
|
||||
const loading = document.querySelector('[data-testid="board-view-loading"]');
|
||||
// Return true only when board-view is visible (store hydrated with project)
|
||||
return boardView !== null;
|
||||
},
|
||||
{ timeout: TIMEOUTS.long }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the worktree selector to be visible
|
||||
*/
|
||||
export async function waitForWorktreeSelector(page: Page): Promise<void> {
|
||||
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
|
||||
// Fallback: wait for "Branch:" text
|
||||
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
|
||||
});
|
||||
}
|
||||
50
apps/ui/tests/utils/helpers/concurrency.ts
Normal file
50
apps/ui/tests/utils/helpers/concurrency.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the concurrency slider container
|
||||
*/
|
||||
export async function getConcurrencySliderContainer(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="concurrency-slider-container"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the concurrency slider
|
||||
*/
|
||||
export async function getConcurrencySlider(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="concurrency-slider"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the displayed concurrency value
|
||||
*/
|
||||
export async function getConcurrencyValue(page: Page): Promise<string | null> {
|
||||
const valueElement = page.locator('[data-testid="concurrency-value"]');
|
||||
return await valueElement.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the concurrency slider value by clicking on the slider track
|
||||
*/
|
||||
export async function setConcurrencyValue(
|
||||
page: Page,
|
||||
targetValue: number,
|
||||
min: number = 1,
|
||||
max: number = 10
|
||||
): Promise<void> {
|
||||
const slider = page.locator('[data-testid="concurrency-slider"]');
|
||||
const sliderBounds = await slider.boundingBox();
|
||||
|
||||
if (!sliderBounds) {
|
||||
throw new Error("Concurrency slider not found or not visible");
|
||||
}
|
||||
|
||||
// Calculate position for target value
|
||||
const percentage = (targetValue - min) / (max - min);
|
||||
const targetX = sliderBounds.x + sliderBounds.width * percentage;
|
||||
const centerY = sliderBounds.y + sliderBounds.height / 2;
|
||||
|
||||
// Click at the target position to set the value
|
||||
await page.mouse.click(targetX, centerY);
|
||||
}
|
||||
137
apps/ui/tests/utils/helpers/log-viewer.ts
Normal file
137
apps/ui/tests/utils/helpers/log-viewer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
|
||||
/**
|
||||
* Get the log viewer header element (contains type counts and expand/collapse buttons)
|
||||
*/
|
||||
export async function getLogViewerHeader(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-viewer-header"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the log viewer header is visible
|
||||
*/
|
||||
export async function isLogViewerHeaderVisible(page: Page): Promise<boolean> {
|
||||
const header = page.locator('[data-testid="log-viewer-header"]');
|
||||
return await header.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log entries container element
|
||||
*/
|
||||
export async function getLogEntriesContainer(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entries-container"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry by its type
|
||||
*/
|
||||
export async function getLogEntryByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-entry-${type}"]`).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all log entries of a specific type
|
||||
*/
|
||||
export async function getAllLogEntriesByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-entry-${type}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count log entries of a specific type
|
||||
*/
|
||||
export async function countLogEntriesByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<number> {
|
||||
const entries = page.locator(`[data-testid="log-entry-${type}"]`);
|
||||
return await entries.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log type count badge by type
|
||||
*/
|
||||
export async function getLogTypeCountBadge(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log type count badge is visible
|
||||
*/
|
||||
export async function isLogTypeCountBadgeVisible(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<boolean> {
|
||||
const badge = page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the expand all button in the log viewer
|
||||
*/
|
||||
export async function clickLogExpandAll(page: Page): Promise<void> {
|
||||
await clickElement(page, "log-expand-all");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the collapse all button in the log viewer
|
||||
*/
|
||||
export async function clickLogCollapseAll(page: Page): Promise<void> {
|
||||
await clickElement(page, "log-collapse-all");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry badge element
|
||||
*/
|
||||
export async function getLogEntryBadge(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entry-badge"]').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any log entry badge is visible
|
||||
*/
|
||||
export async function isLogEntryBadgeVisible(page: Page): Promise<boolean> {
|
||||
const badge = page.locator('[data-testid="log-entry-badge"]').first();
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view mode toggle button (parsed/raw)
|
||||
*/
|
||||
export async function getViewModeButton(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a view mode toggle button
|
||||
*/
|
||||
export async function clickViewModeButton(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<void> {
|
||||
await clickElement(page, `view-mode-${mode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a view mode button is active (selected)
|
||||
*/
|
||||
export async function isViewModeActive(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||
const classes = await button.getAttribute("class");
|
||||
return classes?.includes("text-purple-300") ?? false;
|
||||
}
|
||||
58
apps/ui/tests/utils/helpers/scroll.ts
Normal file
58
apps/ui/tests/utils/helpers/scroll.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Check if an element is scrollable (has scrollable content)
|
||||
*/
|
||||
export async function isElementScrollable(locator: Locator): Promise<boolean> {
|
||||
const scrollInfo = await locator.evaluate((el) => {
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
isScrollable: el.scrollHeight > el.clientHeight,
|
||||
};
|
||||
});
|
||||
return scrollInfo.isScrollable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll an element to the bottom
|
||||
*/
|
||||
export async function scrollToBottom(locator: Locator): Promise<void> {
|
||||
await locator.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scroll position of an element
|
||||
*/
|
||||
export async function getScrollPosition(
|
||||
locator: Locator
|
||||
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
|
||||
return await locator.evaluate((el) => ({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is visible within a scrollable container
|
||||
*/
|
||||
export async function isElementVisibleInScrollContainer(
|
||||
element: Locator,
|
||||
container: Locator
|
||||
): Promise<boolean> {
|
||||
const elementBox = await element.boundingBox();
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
if (!elementBox || !containerBox) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if element is within the visible area of the container
|
||||
return (
|
||||
elementBox.y >= containerBox.y &&
|
||||
elementBox.y + elementBox.height <= containerBox.y + containerBox.height
|
||||
);
|
||||
}
|
||||
49
apps/ui/tests/utils/index.ts
Normal file
49
apps/ui/tests/utils/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Re-export all utilities from their respective modules
|
||||
|
||||
// Core utilities
|
||||
export * from "./core/elements";
|
||||
export * from "./core/interactions";
|
||||
export * from "./core/waiting";
|
||||
export * from "./core/constants";
|
||||
|
||||
// API utilities
|
||||
export * from "./api/client";
|
||||
|
||||
// Git utilities
|
||||
export * from "./git/worktree";
|
||||
|
||||
// Project utilities
|
||||
export * from "./project/setup";
|
||||
export * from "./project/fixtures";
|
||||
|
||||
// Navigation utilities
|
||||
export * from "./navigation/views";
|
||||
|
||||
// View-specific utilities
|
||||
export * from "./views/board";
|
||||
export * from "./views/context";
|
||||
export * from "./views/spec-editor";
|
||||
export * from "./views/agent";
|
||||
export * from "./views/settings";
|
||||
export * from "./views/setup";
|
||||
export * from "./views/profiles";
|
||||
|
||||
// Component utilities
|
||||
export * from "./components/dialogs";
|
||||
export * from "./components/toasts";
|
||||
export * from "./components/modals";
|
||||
export * from "./components/autocomplete";
|
||||
|
||||
// Feature utilities
|
||||
export * from "./features/kanban";
|
||||
export * from "./features/timers";
|
||||
export * from "./features/skip-tests";
|
||||
export * from "./features/waiting-approval";
|
||||
|
||||
// Helper utilities
|
||||
export * from "./helpers/scroll";
|
||||
export * from "./helpers/log-viewer";
|
||||
export * from "./helpers/concurrency";
|
||||
|
||||
// File utilities
|
||||
export * from "./files/drag-drop";
|
||||
116
apps/ui/tests/utils/navigation/views.ts
Normal file
116
apps/ui/tests/utils/navigation/views.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Navigate to the board/kanban view
|
||||
* Note: Navigates directly to /board since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToBoard(page: Page): Promise<void> {
|
||||
// Navigate directly to /board route
|
||||
await page.goto("/board");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the board view to be visible
|
||||
await waitForElement(page, "board-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the context view
|
||||
* Note: Navigates directly to /context since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToContext(page: Page): Promise<void> {
|
||||
// Navigate directly to /context route
|
||||
await page.goto("/context");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the context view to be visible
|
||||
await waitForElement(page, "context-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the spec view
|
||||
* Note: Navigates directly to /spec since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSpec(page: Page): Promise<void> {
|
||||
// Navigate directly to /spec route
|
||||
await page.goto("/spec");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the spec view to be visible
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the agent view
|
||||
* Note: Navigates directly to /agent since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToAgent(page: Page): Promise<void> {
|
||||
// Navigate directly to /agent route
|
||||
await page.goto("/agent");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the agent view to be visible
|
||||
await waitForElement(page, "agent-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the settings view
|
||||
* Note: Navigates directly to /settings since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSettings(page: Page): Promise<void> {
|
||||
// Navigate directly to /settings route
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the settings view to be visible
|
||||
await waitForElement(page, "settings-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the setup view directly
|
||||
* Note: This function uses setupFirstRun from project/setup to avoid circular dependency
|
||||
*/
|
||||
export async function navigateToSetup(page: Page): Promise<void> {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { setupFirstRun } = await import("../project/setup");
|
||||
await setupFirstRun(page);
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await waitForElement(page, "setup-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the welcome view (clear project selection)
|
||||
*/
|
||||
export async function navigateToWelcome(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await waitForElement(page, "welcome-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific view using the sidebar navigation
|
||||
*/
|
||||
export async function navigateToView(
|
||||
page: Page,
|
||||
viewId: string
|
||||
): Promise<void> {
|
||||
const navSelector =
|
||||
viewId === "settings" ? "settings-button" : `nav-${viewId}`;
|
||||
await clickElement(page, navSelector);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current view from the URL or store (checks which view is active)
|
||||
*/
|
||||
export async function getCurrentView(page: Page): Promise<string | null> {
|
||||
// Get the current view from zustand store via localStorage
|
||||
const storage = await page.evaluate(() => {
|
||||
const item = localStorage.getItem("automaker-storage");
|
||||
return item ? JSON.parse(item) : null;
|
||||
});
|
||||
|
||||
return storage?.state?.currentView || null;
|
||||
}
|
||||
121
apps/ui/tests/utils/project/fixtures.ts
Normal file
121
apps/ui/tests/utils/project/fixtures.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Resolve the workspace root - handle both running from apps/ui and from root
|
||||
*/
|
||||
export function getWorkspaceRoot(): string {
|
||||
const cwd = process.cwd();
|
||||
if (cwd.includes("apps/ui")) {
|
||||
return path.resolve(cwd, "../..");
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
const WORKSPACE_ROOT = getWorkspaceRoot();
|
||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
|
||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
|
||||
const CONTEXT_PATH = path.join(FIXTURE_PATH, ".automaker/context");
|
||||
|
||||
// Original spec content for resetting between tests
|
||||
const ORIGINAL_SPEC_CONTENT = `<app_spec>
|
||||
<name>Test Project A</name>
|
||||
<description>A test fixture project for Playwright testing</description>
|
||||
<tech_stack>
|
||||
<item>TypeScript</item>
|
||||
<item>React</item>
|
||||
</tech_stack>
|
||||
</app_spec>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Reset the fixture's app_spec.txt to original content
|
||||
*/
|
||||
export function resetFixtureSpec(): void {
|
||||
const dir = path.dirname(SPEC_FILE_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the context directory to empty state
|
||||
*/
|
||||
export function resetContextDirectory(): void {
|
||||
if (fs.existsSync(CONTEXT_PATH)) {
|
||||
fs.rmSync(CONTEXT_PATH, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context file directly on disk (for test setup)
|
||||
*/
|
||||
export function createContextFileOnDisk(filename: string, content: string): void {
|
||||
const filePath = path.join(CONTEXT_PATH, filename);
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a context file exists on disk
|
||||
*/
|
||||
export function contextFileExistsOnDisk(filename: string): boolean {
|
||||
const filePath = path.join(CONTEXT_PATH, filename);
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to our test fixture
|
||||
* Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
|
||||
*/
|
||||
export async function setupProjectWithFixture(
|
||||
page: Page,
|
||||
projectPath: string = FIXTURE_PATH
|
||||
): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-fixture",
|
||||
name: "projectA",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Also mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture path
|
||||
*/
|
||||
export function getFixturePath(): string {
|
||||
return FIXTURE_PATH;
|
||||
}
|
||||
752
apps/ui/tests/utils/project/setup.ts
Normal file
752
apps/ui/tests/utils/project/setup.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Set up a mock project in localStorage to bypass the welcome screen
|
||||
* This simulates having opened a project before
|
||||
*/
|
||||
export async function setupMockProject(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with custom concurrency value
|
||||
*/
|
||||
export async function setupMockProjectWithConcurrency(
|
||||
page: Page,
|
||||
concurrency: number
|
||||
): Promise<void> {
|
||||
await page.addInitScript((maxConcurrency: number) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: maxConcurrency,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, concurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with specific running tasks to simulate concurrency limit
|
||||
*/
|
||||
export async function setupMockProjectAtConcurrencyLimit(
|
||||
page: Page,
|
||||
maxConcurrency: number = 1,
|
||||
runningTasks: string[] = ["running-task-1"]
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
maxConcurrency,
|
||||
runningTasks,
|
||||
}: {
|
||||
maxConcurrency: number;
|
||||
runningTasks: string[];
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: maxConcurrency,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: runningTasks,
|
||||
autoModeActivityLog: [],
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
},
|
||||
{ maxConcurrency, runningTasks }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features in different states
|
||||
*/
|
||||
export async function setupMockProjectWithFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "verified";
|
||||
steps?: string[];
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Also store features in a global variable that the mock electron API can use
|
||||
// This is needed because the board-view loads features from the file system
|
||||
(window as any).__mockFeatures = mockFeatures;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with a feature context file
|
||||
* This simulates an agent having created context for a feature
|
||||
*/
|
||||
export async function setupMockProjectWithContextFile(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
contextContent: string = "# Agent Context\n\nPrevious implementation work..."
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
featureId,
|
||||
contextContent,
|
||||
}: {
|
||||
featureId: string;
|
||||
contextContent: string;
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Set up mock file system with a context file for the feature
|
||||
// This will be used by the mock electron API
|
||||
// Now uses features/{id}/agent-output.md path
|
||||
(window as any).__mockContextFile = {
|
||||
featureId,
|
||||
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
|
||||
content: contextContent,
|
||||
};
|
||||
},
|
||||
{ featureId, contextContent }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that have startedAt timestamps
|
||||
*/
|
||||
export async function setupMockProjectWithInProgressFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Also store features in a global variable that the mock electron API can use
|
||||
// This is needed because the board-view loads features from the file system
|
||||
(window as any).__mockFeatures = mockFeatures;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with a specific current view for route persistence testing
|
||||
*/
|
||||
export async function setupMockProjectWithView(
|
||||
page: Page,
|
||||
view: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript((currentView: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: currentView,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up an empty localStorage (no projects) to show welcome screen
|
||||
*/
|
||||
export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up mock projects in localStorage but with no current project (for recent projects list)
|
||||
*/
|
||||
export async function setupMockProjectsWithoutCurrent(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "test-project-1",
|
||||
name: "Test Project 1",
|
||||
path: "/mock/test-project-1",
|
||||
lastOpened: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "test-project-2",
|
||||
name: "Test Project 2",
|
||||
path: "/mock/test-project-2",
|
||||
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
|
||||
},
|
||||
];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: mockProjects,
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that have skipTests enabled
|
||||
*/
|
||||
export async function setupMockProjectWithSkipTestsFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
skipTests?: boolean;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock state with multiple projects
|
||||
*/
|
||||
export async function setupMockMultipleProjects(
|
||||
page: Page,
|
||||
projectCount: number = 3
|
||||
): Promise<void> {
|
||||
await page.addInitScript((count: number) => {
|
||||
const mockProjects = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
mockProjects.push({
|
||||
id: `test-project-${i + 1}`,
|
||||
name: `Test Project ${i + 1}`,
|
||||
path: `/mock/test-project-${i + 1}`,
|
||||
lastOpened: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: mockProjects,
|
||||
currentProject: mockProjects[0],
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
}, projectCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with agent output content in the context file
|
||||
*/
|
||||
export async function setupMockProjectWithAgentOutput(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
outputContent: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
featureId,
|
||||
outputContent,
|
||||
}: {
|
||||
featureId: string;
|
||||
outputContent: string;
|
||||
}) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Set up mock file system with output content for the feature
|
||||
// Now uses features/{id}/agent-output.md path
|
||||
(window as any).__mockContextFile = {
|
||||
featureId,
|
||||
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
|
||||
content: outputContent,
|
||||
};
|
||||
},
|
||||
{ featureId, outputContent }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with features that include waiting_approval status
|
||||
*/
|
||||
export async function setupMockProjectWithWaitingApprovalFeatures(
|
||||
page: Page,
|
||||
options?: {
|
||||
maxConcurrency?: number;
|
||||
runningTasks?: string[];
|
||||
features?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: "backlog" | "in_progress" | "waiting_approval" | "verified";
|
||||
steps?: string[];
|
||||
startedAt?: string;
|
||||
skipTests?: boolean;
|
||||
}>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockFeatures = opts?.features || [];
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: opts?.runningTasks ?? [],
|
||||
autoModeActivityLog: [],
|
||||
features: mockFeatures,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Also store features in a global variable that the mock electron API can use
|
||||
(window as any).__mockFeatures = mockFeatures;
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the app store to show setup view (simulate first run)
|
||||
*/
|
||||
export async function setupFirstRun(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
// Clear any existing setup state to simulate first run
|
||||
localStorage.removeItem("automaker-setup");
|
||||
localStorage.removeItem("automaker-storage");
|
||||
|
||||
// Set up the setup store state for first run
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: true,
|
||||
setupComplete: false,
|
||||
currentStep: "welcome",
|
||||
claudeCliStatus: null,
|
||||
claudeAuthStatus: null,
|
||||
claudeInstallProgress: {
|
||||
isInstalling: false,
|
||||
currentStep: "",
|
||||
progress: 0,
|
||||
output: [],
|
||||
},
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
|
||||
// Also set up app store to show setup view
|
||||
const appState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
isAutoModeRunning: false,
|
||||
runningAutoTasks: [],
|
||||
autoModeActivityLog: [],
|
||||
currentView: "setup",
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(appState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the app to skip the setup wizard (setup already complete)
|
||||
*/
|
||||
export async function setupComplete(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with AI profiles for testing the profiles view
|
||||
* Includes default built-in profiles and optionally custom profiles
|
||||
*/
|
||||
export async function setupMockProjectWithProfiles(
|
||||
page: Page,
|
||||
options?: {
|
||||
customProfilesCount?: number;
|
||||
includeBuiltIn?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript((opts: typeof options) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Default built-in profiles (same as DEFAULT_AI_PROFILES from app-store.ts)
|
||||
const builtInProfiles = [
|
||||
{
|
||||
id: "profile-heavy-task",
|
||||
name: "Heavy Task",
|
||||
description:
|
||||
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||
model: "opus" as const,
|
||||
thinkingLevel: "ultrathink" as const,
|
||||
provider: "claude" as const,
|
||||
isBuiltIn: true,
|
||||
icon: "Brain",
|
||||
},
|
||||
{
|
||||
id: "profile-balanced",
|
||||
name: "Balanced",
|
||||
description:
|
||||
"Claude Sonnet with medium thinking for typical development tasks.",
|
||||
model: "sonnet" as const,
|
||||
thinkingLevel: "medium" as const,
|
||||
provider: "claude" as const,
|
||||
isBuiltIn: true,
|
||||
icon: "Scale",
|
||||
},
|
||||
{
|
||||
id: "profile-quick-edit",
|
||||
name: "Quick Edit",
|
||||
description: "Claude Haiku for fast, simple edits and minor fixes.",
|
||||
model: "haiku" as const,
|
||||
thinkingLevel: "none" as const,
|
||||
provider: "claude" as const,
|
||||
isBuiltIn: true,
|
||||
icon: "Zap",
|
||||
},
|
||||
];
|
||||
|
||||
// Generate custom profiles if requested
|
||||
const customProfiles = [];
|
||||
const customCount = opts?.customProfilesCount ?? 0;
|
||||
for (let i = 0; i < customCount; i++) {
|
||||
customProfiles.push({
|
||||
id: `custom-profile-${i + 1}`,
|
||||
name: `Custom Profile ${i + 1}`,
|
||||
description: `Test custom profile ${i + 1}`,
|
||||
model: ["haiku", "sonnet", "opus"][i % 3] as
|
||||
| "haiku"
|
||||
| "sonnet"
|
||||
| "opus",
|
||||
thinkingLevel: ["none", "low", "medium", "high"][i % 4] as
|
||||
| "none"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high",
|
||||
provider: "claude" as const,
|
||||
isBuiltIn: false,
|
||||
icon: ["Brain", "Zap", "Scale", "Cpu", "Rocket", "Sparkles"][i % 6],
|
||||
});
|
||||
}
|
||||
|
||||
// Combine profiles (built-in first, then custom)
|
||||
const includeBuiltIn = opts?.includeBuiltIn !== false; // Default to true
|
||||
const aiProfiles = includeBuiltIn
|
||||
? [...builtInProfiles, ...customProfiles]
|
||||
: customProfiles;
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "", openai: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: aiProfiles,
|
||||
features: [],
|
||||
currentView: "board", // Start at board, will navigate to profiles
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Also mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, options);
|
||||
}
|
||||
98
apps/ui/tests/utils/views/agent.ts
Normal file
98
apps/ui/tests/utils/views/agent.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Get the session list element
|
||||
*/
|
||||
export async function getSessionList(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="session-list"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new session button
|
||||
*/
|
||||
export async function getNewSessionButton(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="new-session-button"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the new session button
|
||||
*/
|
||||
export async function clickNewSessionButton(page: Page): Promise<void> {
|
||||
const button = await getNewSessionButton(page);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session item by its ID
|
||||
*/
|
||||
export async function getSessionItem(
|
||||
page: Page,
|
||||
sessionId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="session-item-${sessionId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the archive button for a session
|
||||
*/
|
||||
export async function clickArchiveSession(
|
||||
page: Page,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const button = page.locator(`[data-testid="archive-session-${sessionId}"]`);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the no session placeholder is visible
|
||||
*/
|
||||
export async function isNoSessionPlaceholderVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
|
||||
return await placeholder.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the no session placeholder to be visible
|
||||
*/
|
||||
export async function waitForNoSessionPlaceholder(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "no-session-placeholder", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the message list is visible (indicates a session is selected)
|
||||
*/
|
||||
export async function isMessageListVisible(page: Page): Promise<boolean> {
|
||||
const messageList = page.locator('[data-testid="message-list"]');
|
||||
return await messageList.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of session items in the session list
|
||||
*/
|
||||
export async function countSessionItems(page: Page): Promise<number> {
|
||||
const sessionList = page.locator(
|
||||
'[data-testid="session-list"] [data-testid^="session-item-"]'
|
||||
);
|
||||
return await sessionList.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a new session to be created (by checking if a session item appears)
|
||||
*/
|
||||
export async function waitForNewSession(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
// Wait for any session item to appear
|
||||
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
|
||||
await sessionItem.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
});
|
||||
}
|
||||
257
apps/ui/tests/utils/views/board.ts
Normal file
257
apps/ui/tests/utils/views/board.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get a kanban card by feature ID
|
||||
*/
|
||||
export async function getKanbanCard(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a kanban column by its ID
|
||||
*/
|
||||
export async function getKanbanColumn(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width of a kanban column
|
||||
*/
|
||||
export async function getKanbanColumnWidth(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<number> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const box = await column.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kanban column has CSS columns (masonry) layout
|
||||
*/
|
||||
export async function hasKanbanColumnMasonryLayout(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<boolean> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
|
||||
|
||||
const columnCount = await contentDiv.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.columnCount;
|
||||
});
|
||||
|
||||
return columnCount === "2";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a kanban card from one column to another
|
||||
*/
|
||||
export async function dragKanbanCard(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
targetColumnId: string
|
||||
): Promise<void> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const targetColumn = page.locator(
|
||||
`[data-testid="kanban-column-${targetColumnId}"]`
|
||||
);
|
||||
|
||||
// Perform drag and drop
|
||||
await dragHandle.dragTo(targetColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the view output button on a kanban card
|
||||
*/
|
||||
export async function clickViewOutput(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
// Try the running version first, then the in-progress version
|
||||
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
||||
const inProgressBtn = page.locator(
|
||||
`[data-testid="view-output-inprogress-${featureId}"]`
|
||||
);
|
||||
|
||||
if (await runningBtn.isVisible()) {
|
||||
await runningBtn.click();
|
||||
} else if (await inProgressBtn.isVisible()) {
|
||||
await inProgressBtn.click();
|
||||
} else {
|
||||
throw new Error(`View output button not found for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the drag handle is visible for a specific feature card
|
||||
*/
|
||||
export async function isDragHandleVisibleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
return await dragHandle.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the drag handle element for a specific feature card
|
||||
*/
|
||||
export async function getDragHandleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Feature Dialog
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click the add feature button
|
||||
*/
|
||||
export async function clickAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="add-feature-button"]');
|
||||
await page.waitForSelector('[data-testid="add-feature-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the add feature dialog
|
||||
*/
|
||||
export async function fillAddFeatureDialog(
|
||||
page: Page,
|
||||
description: string,
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
// Fill description (using the dropzone textarea)
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(description);
|
||||
|
||||
// Fill branch if provided (it's a combobox autocomplete)
|
||||
if (options?.branch) {
|
||||
// First, select "Other branch" radio option if not already selected
|
||||
const otherBranchRadio = page
|
||||
.locator('[data-testid="feature-radio-group"]')
|
||||
.locator('[id="feature-other"]');
|
||||
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
|
||||
await otherBranchRadio.click();
|
||||
// Wait for the branch input to appear
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Now click on the branch input (autocomplete)
|
||||
const branchInput = page.locator('[data-testid="feature-input"]');
|
||||
await branchInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await branchInput.click();
|
||||
// Wait for the popover to open
|
||||
await page.waitForTimeout(300);
|
||||
// Type in the command input
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.branch);
|
||||
// Press Enter to select/create the branch
|
||||
await commandInput.press("Enter");
|
||||
// Wait for popover to close
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Fill category if provided (it's also a combobox autocomplete)
|
||||
if (options?.category) {
|
||||
const categoryButton = page.locator(
|
||||
'[data-testid="feature-category-input"]'
|
||||
);
|
||||
await categoryButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.category);
|
||||
await commandInput.press("Enter");
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the add feature dialog
|
||||
*/
|
||||
export async function confirmAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="confirm-add-feature"]');
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a feature with all steps in one call
|
||||
*/
|
||||
export async function addFeature(
|
||||
page: Page,
|
||||
description: string,
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, description, options);
|
||||
await confirmAddFeature(page);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Selector
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the worktree selector element
|
||||
*/
|
||||
export async function getWorktreeSelector(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="worktree-selector"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a branch button in the worktree selector
|
||||
*/
|
||||
export async function selectWorktreeBranch(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
});
|
||||
await branchButton.click();
|
||||
await page.waitForTimeout(500); // Wait for UI to update
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected branch in the worktree selector
|
||||
*/
|
||||
export async function getSelectedWorktreeBranch(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
// The main branch button has aria-pressed="true" when selected
|
||||
const selectedButton = page.locator(
|
||||
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
|
||||
);
|
||||
const text = await selectedButton.textContent().catch(() => null);
|
||||
return text?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch button is visible in the worktree selector
|
||||
*/
|
||||
export async function isWorktreeBranchVisible(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
});
|
||||
return await branchButton.isVisible().catch(() => false);
|
||||
}
|
||||
185
apps/ui/tests/utils/views/context.ts
Normal file
185
apps/ui/tests/utils/views/context.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement, fillInput } from "../core/interactions";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
import { getByTestId } from "../core/elements";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the context file list element
|
||||
*/
|
||||
export async function getContextFileList(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="context-file-list"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a context file in the list
|
||||
*/
|
||||
export async function clickContextFile(
|
||||
page: Page,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
||||
await fileButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context editor element
|
||||
*/
|
||||
export async function getContextEditor(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="context-editor"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context editor content
|
||||
*/
|
||||
export async function getContextEditorContent(page: Page): Promise<string> {
|
||||
const editor = await getByTestId(page, "context-editor");
|
||||
return await editor.inputValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the context editor content
|
||||
*/
|
||||
export async function setContextEditorContent(
|
||||
page: Page,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const editor = await getByTestId(page, "context-editor");
|
||||
await editor.fill(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the add context file dialog
|
||||
*/
|
||||
export async function openAddContextFileDialog(page: Page): Promise<void> {
|
||||
await clickElement(page, "add-context-file");
|
||||
await waitForElement(page, "add-context-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text context file via the UI
|
||||
*/
|
||||
export async function createContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
await openAddContextFileDialog(page);
|
||||
await clickElement(page, "add-text-type");
|
||||
await fillInput(page, "new-file-name", filename);
|
||||
await fillInput(page, "new-file-content", content);
|
||||
await clickElement(page, "confirm-add-file");
|
||||
await waitForElementHidden(page, "add-context-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image context file via the UI
|
||||
*/
|
||||
export async function createContextImage(
|
||||
page: Page,
|
||||
filename: string,
|
||||
imagePath: string
|
||||
): Promise<void> {
|
||||
await openAddContextFileDialog(page);
|
||||
await clickElement(page, "add-image-type");
|
||||
await fillInput(page, "new-file-name", filename);
|
||||
await page.setInputFiles('[data-testid="image-upload-input"]', imagePath);
|
||||
await clickElement(page, "confirm-add-file");
|
||||
await waitForElementHidden(page, "add-context-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a context file via the UI (must be selected first)
|
||||
*/
|
||||
export async function deleteSelectedContextFile(page: Page): Promise<void> {
|
||||
await clickElement(page, "delete-context-file");
|
||||
await waitForElement(page, "delete-context-dialog");
|
||||
await clickElement(page, "confirm-delete-file");
|
||||
await waitForElementHidden(page, "delete-context-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current context file
|
||||
*/
|
||||
export async function saveContextFile(page: Page): Promise<void> {
|
||||
await clickElement(page, "save-context-file");
|
||||
// Wait for save to complete (button shows "Saved")
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="save-context-file"]')
|
||||
?.textContent?.includes("Saved"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle markdown preview mode
|
||||
*/
|
||||
export async function toggleContextPreviewMode(page: Page): Promise<void> {
|
||||
await clickElement(page, "toggle-preview-mode");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific file to appear in the context file list
|
||||
*/
|
||||
export async function waitForContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
const locator = await getByTestId(page, `context-file-${filename}`);
|
||||
await locator.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a file in the list and wait for it to be selected (toolbar visible)
|
||||
* Uses JavaScript click to ensure React event handler fires
|
||||
*/
|
||||
export async function selectContextFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
const fileButton = await getByTestId(page, `context-file-${filename}`);
|
||||
await fileButton.waitFor({ state: "visible", timeout });
|
||||
|
||||
// Use JavaScript click to ensure React onClick handler fires
|
||||
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
|
||||
|
||||
// Wait for the file to be selected (toolbar with delete button becomes visible)
|
||||
const deleteButton = await getByTestId(page, "delete-context-file");
|
||||
await expect(deleteButton).toBeVisible({
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for file content panel to load (either editor, preview, or image)
|
||||
*/
|
||||
export async function waitForFileContentToLoad(page: Page): Promise<void> {
|
||||
// Wait for either the editor, preview, or image to appear
|
||||
await page.waitForSelector(
|
||||
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]',
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch from preview mode to edit mode for markdown files
|
||||
* Markdown files open in preview mode by default, this helper switches to edit mode
|
||||
*/
|
||||
export async function switchToEditMode(page: Page): Promise<void> {
|
||||
// First wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
const markdownPreview = await getByTestId(page, "markdown-preview");
|
||||
const isPreview = await markdownPreview.isVisible().catch(() => false);
|
||||
|
||||
if (isPreview) {
|
||||
await clickElement(page, "toggle-preview-mode");
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
572
apps/ui/tests/utils/views/profiles.ts
Normal file
572
apps/ui/tests/utils/views/profiles.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement, fillInput } from "../core/interactions";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
import { getByTestId } from "../core/elements";
|
||||
import { navigateToView } from "../navigation/views";
|
||||
|
||||
/**
|
||||
* Navigate to the profiles view
|
||||
*/
|
||||
export async function navigateToProfiles(page: Page): Promise<void> {
|
||||
// Click the profiles navigation button
|
||||
await navigateToView(page, "profiles");
|
||||
|
||||
// Wait for profiles view to be visible
|
||||
await page.waitForSelector('[data-testid="profiles-view"]', {
|
||||
state: "visible",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile List Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a specific profile card by ID
|
||||
*/
|
||||
export async function getProfileCard(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<Locator> {
|
||||
return getByTestId(page, `profile-card-${profileId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all profile cards (both built-in and custom)
|
||||
*/
|
||||
export async function getProfileCards(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid^="profile-card-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only custom profile cards
|
||||
*/
|
||||
export async function getCustomProfiles(page: Page): Promise<Locator> {
|
||||
// Custom profiles don't have the "Built-in" badge
|
||||
return page.locator('[data-testid^="profile-card-"]').filter({
|
||||
hasNot: page.locator('text="Built-in"'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only built-in profile cards
|
||||
*/
|
||||
export async function getBuiltInProfiles(page: Page): Promise<Locator> {
|
||||
// Built-in profiles have the lock icon and "Built-in" text
|
||||
return page.locator('[data-testid^="profile-card-"]:has-text("Built-in")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of custom profiles
|
||||
*/
|
||||
export async function countCustomProfiles(page: Page): Promise<number> {
|
||||
const customProfiles = await getCustomProfiles(page);
|
||||
return customProfiles.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of built-in profiles
|
||||
*/
|
||||
export async function countBuiltInProfiles(page: Page): Promise<number> {
|
||||
const builtInProfiles = await getBuiltInProfiles(page);
|
||||
return await builtInProfiles.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom profile IDs
|
||||
*/
|
||||
export async function getCustomProfileIds(page: Page): Promise<string[]> {
|
||||
const allCards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
const customIds: string[] = [];
|
||||
|
||||
for (const card of allCards) {
|
||||
const builtInText = card.locator('text="Built-in"');
|
||||
const isBuiltIn = (await builtInText.count()) > 0;
|
||||
if (!isBuiltIn) {
|
||||
const testId = await card.getAttribute("data-testid");
|
||||
if (testId) {
|
||||
// Extract ID from "profile-card-{id}"
|
||||
const profileId = testId.replace("profile-card-", "");
|
||||
customIds.push(profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return customIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first custom profile ID (useful after creating a profile)
|
||||
*/
|
||||
export async function getFirstCustomProfileId(page: Page): Promise<string | null> {
|
||||
const ids = await getCustomProfileIds(page);
|
||||
return ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click the "New Profile" button in the header
|
||||
*/
|
||||
export async function clickNewProfileButton(page: Page): Promise<void> {
|
||||
await clickElement(page, "add-profile-button");
|
||||
await waitForElement(page, "add-profile-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the empty state card to create a new profile
|
||||
*/
|
||||
export async function clickEmptyState(page: Page): Promise<void> {
|
||||
const emptyState = page.locator(
|
||||
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
|
||||
);
|
||||
await emptyState.click();
|
||||
await waitForElement(page, "add-profile-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the profile form with data
|
||||
*/
|
||||
export async function fillProfileForm(
|
||||
page: Page,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (data.name !== undefined) {
|
||||
await fillProfileName(page, data.name);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
await fillProfileDescription(page, data.description);
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
await selectIcon(page, data.icon);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
await selectModel(page, data.model);
|
||||
}
|
||||
if (data.thinkingLevel !== undefined) {
|
||||
await selectThinkingLevel(page, data.thinkingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save button to create/update a profile
|
||||
*/
|
||||
export async function saveProfile(page: Page): Promise<void> {
|
||||
await clickElement(page, "save-profile-button");
|
||||
// Wait for dialog to close
|
||||
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
|
||||
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the cancel button in the profile dialog
|
||||
*/
|
||||
export async function cancelProfileDialog(page: Page): Promise<void> {
|
||||
// Look for cancel button in dialog footer
|
||||
const cancelButton = page.locator('button:has-text("Cancel")');
|
||||
await cancelButton.click();
|
||||
// Wait for dialog to close
|
||||
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
|
||||
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the edit button for a specific profile
|
||||
*/
|
||||
export async function clickEditProfile(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `edit-profile-${profileId}`);
|
||||
await waitForElement(page, "edit-profile-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the delete button for a specific profile
|
||||
*/
|
||||
export async function clickDeleteProfile(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `delete-profile-${profileId}`);
|
||||
await waitForElement(page, "delete-profile-confirm-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm profile deletion in the dialog
|
||||
*/
|
||||
export async function confirmDeleteProfile(page: Page): Promise<void> {
|
||||
await clickElement(page, "confirm-delete-profile-button");
|
||||
await waitForElementHidden(page, "delete-profile-confirm-dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel profile deletion
|
||||
*/
|
||||
export async function cancelDeleteProfile(page: Page): Promise<void> {
|
||||
await clickElement(page, "cancel-delete-button");
|
||||
await waitForElementHidden(page, "delete-profile-confirm-dialog");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Field Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fill the profile name field
|
||||
*/
|
||||
export async function fillProfileName(
|
||||
page: Page,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
await fillInput(page, "profile-name-input", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the profile description field
|
||||
*/
|
||||
export async function fillProfileDescription(
|
||||
page: Page,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
await fillInput(page, "profile-description-input", description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an icon for the profile
|
||||
* @param iconName - Name of the icon: Brain, Zap, Scale, Cpu, Rocket, Sparkles
|
||||
*/
|
||||
export async function selectIcon(page: Page, iconName: string): Promise<void> {
|
||||
await clickElement(page, `icon-select-${iconName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a model for the profile
|
||||
* @param modelId - Model ID: haiku, sonnet, opus
|
||||
*/
|
||||
export async function selectModel(page: Page, modelId: string): Promise<void> {
|
||||
await clickElement(page, `model-select-${modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a thinking level for the profile
|
||||
* @param level - Thinking level: none, low, medium, high, ultrathink
|
||||
*/
|
||||
export async function selectThinkingLevel(
|
||||
page: Page,
|
||||
level: string
|
||||
): Promise<void> {
|
||||
await clickElement(page, `thinking-select-${level}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected icon
|
||||
*/
|
||||
export async function getSelectedIcon(page: Page): Promise<string | null> {
|
||||
// Find the icon button with primary background
|
||||
const selectedIcon = page.locator(
|
||||
'[data-testid^="icon-select-"][class*="bg-primary"]'
|
||||
);
|
||||
const testId = await selectedIcon.getAttribute("data-testid");
|
||||
return testId ? testId.replace("icon-select-", "") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected model
|
||||
*/
|
||||
export async function getSelectedModel(page: Page): Promise<string | null> {
|
||||
// Find the model button with primary background
|
||||
const selectedModel = page.locator(
|
||||
'[data-testid^="model-select-"][class*="bg-primary"]'
|
||||
);
|
||||
const testId = await selectedModel.getAttribute("data-testid");
|
||||
return testId ? testId.replace("model-select-", "") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected thinking level
|
||||
*/
|
||||
export async function getSelectedThinkingLevel(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
// Find the thinking level button with amber background
|
||||
const selectedLevel = page.locator(
|
||||
'[data-testid^="thinking-select-"][class*="bg-amber-500"]'
|
||||
);
|
||||
const testId = await selectedLevel.getAttribute("data-testid");
|
||||
return testId ? testId.replace("thinking-select-", "") : null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialog Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if the add profile dialog is open
|
||||
*/
|
||||
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
|
||||
const dialog = await getByTestId(page, "add-profile-dialog");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit profile dialog is open
|
||||
*/
|
||||
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
|
||||
const dialog = await getByTestId(page, "edit-profile-dialog");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the delete confirmation dialog is open
|
||||
*/
|
||||
export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
|
||||
const dialog = await getByTestId(page, "delete-profile-confirm-dialog");
|
||||
return await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any profile dialog to close
|
||||
* This ensures all dialog animations complete before proceeding
|
||||
*/
|
||||
export async function waitForDialogClose(page: Page): Promise<void> {
|
||||
// Wait for all profile dialogs to be hidden
|
||||
await Promise.all([
|
||||
waitForElementHidden(page, "add-profile-dialog").catch(() => {}),
|
||||
waitForElementHidden(page, "edit-profile-dialog").catch(() => {}),
|
||||
waitForElementHidden(page, "delete-profile-confirm-dialog").catch(
|
||||
() => {}
|
||||
),
|
||||
]);
|
||||
|
||||
// Also wait for any Radix dialog overlay to be removed (handles animation)
|
||||
await page
|
||||
.locator('[data-radix-dialog-overlay]')
|
||||
.waitFor({ state: "hidden", timeout: 2000 })
|
||||
.catch(() => {
|
||||
// Overlay may not exist
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Card Inspection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the profile name from a card
|
||||
*/
|
||||
export async function getProfileName(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<string> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
const nameElement = card.locator("h3");
|
||||
return await nameElement.textContent().then((text) => text?.trim() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile description from a card
|
||||
*/
|
||||
export async function getProfileDescription(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<string> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
const descElement = card.locator("p").first();
|
||||
return await descElement.textContent().then((text) => text?.trim() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile model badge text from a card
|
||||
*/
|
||||
export async function getProfileModel(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<string> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
const modelBadge = card.locator(
|
||||
'span[class*="border-primary"]:has-text("haiku"), span[class*="border-primary"]:has-text("sonnet"), span[class*="border-primary"]:has-text("opus")'
|
||||
);
|
||||
return await modelBadge.textContent().then((text) => text?.trim() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile thinking level badge text from a card
|
||||
*/
|
||||
export async function getProfileThinkingLevel(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<string | null> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
const thinkingBadge = card.locator('span[class*="border-amber-500"]');
|
||||
const isVisible = await thinkingBadge.isVisible().catch(() => false);
|
||||
if (!isVisible) return null;
|
||||
return await thinkingBadge.textContent().then((text) => text?.trim() || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile has the built-in badge
|
||||
*/
|
||||
export async function isBuiltInProfile(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<boolean> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
const builtInBadge = card.locator('span:has-text("Built-in")');
|
||||
return await builtInBadge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the edit button is visible for a profile
|
||||
*/
|
||||
export async function isEditButtonVisible(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<boolean> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
// Hover over card to make buttons visible
|
||||
await card.hover();
|
||||
const editButton = await getByTestId(page, `edit-profile-${profileId}`);
|
||||
// Wait for button to become visible after hover (handles CSS transition)
|
||||
try {
|
||||
await editButton.waitFor({ state: "visible", timeout: 2000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the delete button is visible for a profile
|
||||
*/
|
||||
export async function isDeleteButtonVisible(
|
||||
page: Page,
|
||||
profileId: string
|
||||
): Promise<boolean> {
|
||||
const card = await getProfileCard(page, profileId);
|
||||
// Hover over card to make buttons visible
|
||||
await card.hover();
|
||||
const deleteButton = await getByTestId(page, `delete-profile-${profileId}`);
|
||||
// Wait for button to become visible after hover (handles CSS transition)
|
||||
try {
|
||||
await deleteButton.waitFor({ state: "visible", timeout: 2000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Drag & Drop
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Drag a profile from one position to another
|
||||
* Uses the drag handle and dnd-kit library pattern
|
||||
*
|
||||
* Note: dnd-kit requires pointer events with specific timing for drag recognition.
|
||||
* Manual mouse operations are needed because Playwright's dragTo doesn't work
|
||||
* reliably with dnd-kit's pointer-based drag detection.
|
||||
*
|
||||
* @param fromIndex - 0-based index of the profile to drag
|
||||
* @param toIndex - 0-based index of the target position
|
||||
*/
|
||||
export async function dragProfile(
|
||||
page: Page,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): Promise<void> {
|
||||
// Get all profile cards
|
||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
|
||||
if (fromIndex >= cards.length || toIndex >= cards.length) {
|
||||
throw new Error(
|
||||
`Invalid drag indices: fromIndex=${fromIndex}, toIndex=${toIndex}, total=${cards.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const fromCard = cards[fromIndex];
|
||||
const toCard = cards[toIndex];
|
||||
|
||||
// Get the drag handle within the source card
|
||||
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
|
||||
|
||||
// Ensure drag handle is visible and ready
|
||||
await dragHandle.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Get bounding boxes
|
||||
const handleBox = await dragHandle.boundingBox();
|
||||
const toBox = await toCard.boundingBox();
|
||||
|
||||
if (!handleBox || !toBox) {
|
||||
throw new Error("Unable to get bounding boxes for drag operation");
|
||||
}
|
||||
|
||||
// Start position (center of drag handle)
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
// End position (center of target card)
|
||||
const endX = toBox.x + toBox.width / 2;
|
||||
const endY = toBox.y + toBox.height / 2;
|
||||
|
||||
// Perform manual drag operation
|
||||
// dnd-kit needs pointer events in a specific sequence
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
|
||||
// dnd-kit requires a brief hold before recognizing the drag gesture
|
||||
// This is a library requirement, not an arbitrary timeout
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// Move to target in steps for smoother drag recognition
|
||||
await page.mouse.move(endX, endY, { steps: 10 });
|
||||
|
||||
// Brief pause before drop
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for reorder animation to complete
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current order of all profile IDs
|
||||
* Returns array of profile IDs in display order
|
||||
*/
|
||||
export async function getProfileOrder(page: Page): Promise<string[]> {
|
||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const card of cards) {
|
||||
const testId = await card.getAttribute("data-testid");
|
||||
if (testId) {
|
||||
// Extract profile ID from data-testid="profile-card-{id}"
|
||||
const profileId = testId.replace("profile-card-", "");
|
||||
ids.push(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Header Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click the "Refresh Defaults" button
|
||||
*/
|
||||
export async function clickRefreshDefaults(page: Page): Promise<void> {
|
||||
await clickElement(page, "refresh-profiles-button");
|
||||
}
|
||||
8
apps/ui/tests/utils/views/settings.ts
Normal file
8
apps/ui/tests/utils/views/settings.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the settings view scrollable content area
|
||||
*/
|
||||
export async function getSettingsContentArea(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="settings-view"] .overflow-y-auto');
|
||||
}
|
||||
75
apps/ui/tests/utils/views/setup.ts
Normal file
75
apps/ui/tests/utils/views/setup.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { getByTestId } from "../core/elements";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
import { setupFirstRun } from "../project/setup";
|
||||
|
||||
/**
|
||||
* Wait for setup view to be visible
|
||||
*/
|
||||
export async function waitForSetupView(page: Page): Promise<Locator> {
|
||||
return waitForElement(page, "setup-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Get Started" button on setup welcome step
|
||||
*/
|
||||
export async function clickSetupGetStarted(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "setup-start-button");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click continue on Claude setup step
|
||||
*/
|
||||
export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "claude-next-button");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click finish on setup complete step
|
||||
*/
|
||||
export async function clickSetupFinish(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "setup-finish-button");
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter Anthropic API key in setup
|
||||
*/
|
||||
export async function enterAnthropicApiKey(
|
||||
page: Page,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
// Click "Use Anthropic API Key Instead" button
|
||||
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
|
||||
await useApiKeyButton.click();
|
||||
|
||||
// Enter the API key
|
||||
const input = await getByTestId(page, "anthropic-api-key-input");
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save button
|
||||
const saveButton = await getByTestId(page, "save-anthropic-key-button");
|
||||
await saveButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter OpenAI API key in setup
|
||||
*/
|
||||
export async function enterOpenAIApiKey(
|
||||
page: Page,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
// Click "Enter OpenAI API Key" button
|
||||
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
|
||||
await useApiKeyButton.click();
|
||||
|
||||
// Enter the API key
|
||||
const input = await getByTestId(page, "openai-api-key-input");
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save button
|
||||
const saveButton = await getByTestId(page, "save-openai-key-button");
|
||||
await saveButton.click();
|
||||
}
|
||||
118
apps/ui/tests/utils/views/spec-editor.ts
Normal file
118
apps/ui/tests/utils/views/spec-editor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { clickElement } from "../core/interactions";
|
||||
import { navigateToSpec } from "../navigation/views";
|
||||
|
||||
/**
|
||||
* Get the spec editor element
|
||||
*/
|
||||
export async function getSpecEditor(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="spec-editor"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the spec editor content
|
||||
*/
|
||||
export async function getSpecEditorContent(page: Page): Promise<string> {
|
||||
const editor = await getSpecEditor(page);
|
||||
return await editor.inputValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the spec editor content
|
||||
*/
|
||||
export async function setSpecEditorContent(
|
||||
page: Page,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const editor = await getSpecEditor(page);
|
||||
await editor.fill(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save spec button
|
||||
*/
|
||||
export async function clickSaveSpec(page: Page): Promise<void> {
|
||||
await clickElement(page, "save-spec");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the reload spec button
|
||||
*/
|
||||
export async function clickReloadSpec(page: Page): Promise<void> {
|
||||
await clickElement(page, "reload-spec");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the spec view path display shows the correct .automaker path
|
||||
*/
|
||||
export async function getDisplayedSpecPath(page: Page): Promise<string | null> {
|
||||
const specView = page.locator('[data-testid="spec-view"]');
|
||||
const pathElement = specView.locator("p.text-muted-foreground").first();
|
||||
return await pathElement.textContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the spec editor view
|
||||
*/
|
||||
export async function navigateToSpecEditor(page: Page): Promise<void> {
|
||||
await navigateToSpec(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CodeMirror editor content
|
||||
*/
|
||||
export async function getEditorContent(page: Page): Promise<string> {
|
||||
// CodeMirror uses a contenteditable div with class .cm-content
|
||||
const content = await page
|
||||
.locator('[data-testid="spec-editor"] .cm-content')
|
||||
.textContent();
|
||||
return content || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the CodeMirror editor content by selecting all and typing
|
||||
*/
|
||||
export async function setEditorContent(page: Page, content: string): Promise<void> {
|
||||
// Click on the editor to focus it
|
||||
const editor = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||
await editor.click();
|
||||
|
||||
// Wait for focus
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Select all content (Cmd+A on Mac, Ctrl+A on others)
|
||||
const isMac = process.platform === "darwin";
|
||||
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
|
||||
// Wait for selection
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Delete the selected content first
|
||||
await page.keyboard.press("Backspace");
|
||||
|
||||
// Wait for deletion
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Type the new content
|
||||
await page.keyboard.type(content, { delay: 10 });
|
||||
|
||||
// Wait for typing to complete
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the save button
|
||||
*/
|
||||
export async function clickSaveButton(page: Page): Promise<void> {
|
||||
const saveButton = page.locator('[data-testid="save-spec"]');
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for the button text to change to "Saved" indicating save is complete
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const btn = document.querySelector('[data-testid="save-spec"]');
|
||||
return btn?.textContent?.includes("Saved");
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user