mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 23:13:07 +00:00
refactor: move from next js to vite and tanstack router
This commit is contained in:
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",
|
||||
});
|
||||
}
|
||||
226
apps/ui/tests/utils/views/board.ts
Normal file
226
apps/ui/tests/utils/views/board.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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) {
|
||||
const branchButton = page.locator('[data-testid="feature-branch-input"]');
|
||||
await branchButton.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