File size: 10,319 Bytes
1dbc34b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | import { Page, Locator } from '@playwright/test';
import {
clickElement,
fillInput,
handleLoginScreenIfPresent,
closeDialogWithEscape,
} from '../core/interactions';
import { waitForElement, waitForElementHidden } from '../core/waiting';
import { getByTestId } from '../core/elements';
import { expect } from '@playwright/test';
import { authenticateForTests } from '../api/client';
/**
* Get the memory file list element
*/
export async function getMemoryFileList(page: Page): Promise<Locator> {
return page.locator('[data-testid="memory-file-list"]');
}
/**
* Click on a memory file in the list
*/
export async function clickMemoryFile(page: Page, fileName: string): Promise<void> {
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
await fileButton.click();
}
/**
* Get the memory editor element
*/
export async function getMemoryEditor(page: Page): Promise<Locator> {
return page.locator('[data-testid="memory-editor"]');
}
/**
* Get the memory editor content
*/
export async function getMemoryEditorContent(page: Page): Promise<string> {
const editor = await getByTestId(page, 'memory-editor');
return await editor.inputValue();
}
/**
* Set the memory editor content
*/
export async function setMemoryEditorContent(page: Page, content: string): Promise<void> {
const editor = await getByTestId(page, 'memory-editor');
await editor.fill(content);
}
/**
* Open the create memory file dialog
*/
export async function openCreateMemoryDialog(page: Page): Promise<void> {
await clickElement(page, 'create-memory-button');
await waitForElement(page, 'create-memory-dialog');
}
/**
* Create a memory file via the UI
*/
export async function createMemoryFile(
page: Page,
filename: string,
content: string
): Promise<void> {
await openCreateMemoryDialog(page);
await fillInput(page, 'new-memory-name', filename);
await fillInput(page, 'new-memory-content', content);
await clickElement(page, 'confirm-create-memory');
await waitForElementHidden(page, 'create-memory-dialog');
}
/**
* Delete a memory file via the UI (must be selected first)
*/
export async function deleteSelectedMemoryFile(page: Page): Promise<void> {
await clickElement(page, 'delete-memory-file');
await waitForElement(page, 'delete-memory-dialog');
await clickElement(page, 'confirm-delete-memory');
await waitForElementHidden(page, 'delete-memory-dialog');
}
/**
* Save the current memory file
*/
export async function saveMemoryFile(page: Page): Promise<void> {
await clickElement(page, 'save-memory-file');
// Wait for save to complete across desktop/mobile variants
// On desktop: button text shows "Saved"
// On mobile: icon-only button uses aria-label or title
await page.waitForFunction(
() => {
const btn = document.querySelector('[data-testid="save-memory-file"]');
if (!btn) return false;
const stateText = [
btn.textContent ?? '',
btn.getAttribute('aria-label') ?? '',
btn.getAttribute('title') ?? '',
]
.join(' ')
.toLowerCase();
return stateText.includes('saved');
},
{ timeout: 5000 }
);
}
/**
* Toggle markdown preview mode
*/
export async function toggleMemoryPreviewMode(page: Page): Promise<void> {
await clickElement(page, 'toggle-preview-mode');
}
/**
* Wait for a specific file to appear in the memory file list
* Uses retry mechanism to handle race conditions with API/UI updates
*/
export async function waitForMemoryFile(
page: Page,
filename: string,
timeout: number = 15000
): Promise<void> {
await expect(async () => {
const locator = page.locator(`[data-testid="memory-file-${filename}"]`);
await expect(locator).toBeVisible();
}).toPass({ timeout, intervals: [200, 500, 1000] });
}
/**
* Click a file in the list and wait for it to be selected (toolbar visible)
* Uses retry mechanism to handle race conditions where element is visible but not yet interactive
*/
export async function selectMemoryFile(
page: Page,
filename: string,
timeout: number = 15000
): Promise<void> {
const fileButton = await getByTestId(page, `memory-file-${filename}`);
// Retry click + wait for content panel to handle timing issues
// Note: On mobile, delete button is hidden, so we wait for content panel instead
// Use shorter inner timeout so retries can run; loadFileContent is async (API read)
const innerTimeout = Math.min(2000, Math.floor(timeout / 3));
await expect(async () => {
// Use JavaScript click to ensure React onClick handler fires
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
// Wait for content to appear (editor or preview)
const contentLocator = page.locator(
'[data-testid="memory-editor"], [data-testid="markdown-preview"]'
);
await expect(contentLocator).toBeVisible({ timeout: innerTimeout });
}).toPass({ timeout, intervals: [200, 500, 1000] });
}
/**
* Wait for file content panel to load (either editor or preview)
* Uses retry mechanism to handle race conditions with file selection
*/
export async function waitForMemoryContentToLoad(
page: Page,
timeout: number = 15000
): Promise<void> {
const innerTimeout = Math.min(2000, Math.floor(timeout / 3));
await expect(async () => {
const contentLocator = page.locator(
'[data-testid="memory-editor"], [data-testid="markdown-preview"]'
);
await expect(contentLocator).toBeVisible({ timeout: innerTimeout });
}).toPass({ timeout, intervals: [200, 500, 1000] });
}
/**
* Switch from preview mode to edit mode for memory files
* Memory files open in preview mode by default, this helper switches to edit mode
*/
export async function switchMemoryToEditMode(page: Page): Promise<void> {
// First wait for content to load
await waitForMemoryContentToLoad(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="memory-editor"]', {
timeout: 5000,
});
}
}
/**
* Refresh the memory file list (clicks the Refresh button).
* Use instead of page.reload() to avoid ERR_CONNECTION_REFUSED when the dev server
* is under load, and to match real user behavior.
*/
export async function refreshMemoryList(page: Page): Promise<void> {
// Desktop: refresh button is visible; mobile: open panel then click mobile refresh
const desktopRefresh = page.locator('[data-testid="refresh-memory-button"]');
const mobileRefresh = page.locator('[data-testid="refresh-memory-button-mobile"]');
if (await desktopRefresh.isVisible().catch(() => false)) {
await desktopRefresh.click();
} else {
await clickElement(page, 'header-actions-panel-trigger');
await mobileRefresh.click();
}
// Allow list to re-fetch
await page.waitForTimeout(150);
}
/**
* Navigate to the memory view
* Note: Navigates directly to /memory since index route shows WelcomeView
*/
export async function navigateToMemory(page: Page): Promise<void> {
// Authenticate before navigating (fast-path: skips if already authed via storageState)
await authenticateForTests(page);
// Navigate directly to /memory route
await page.goto('/memory', { waitUntil: 'domcontentloaded' });
// Handle login redirect if needed (e.g. when redirected to /logged-out)
await handleLoginScreenIfPresent(page);
// Wait for one of: memory-view, memory-view-no-project, or memory-view-loading.
// Store hydration and loadMemoryFiles can be async, so we accept any of these first.
const viewSelector =
'[data-testid="memory-view"], [data-testid="memory-view-no-project"], [data-testid="memory-view-loading"]';
await page.locator(viewSelector).first().waitFor({ state: 'visible', timeout: 15000 });
// If we see "no project", give hydration a moment then re-check (avoids flake when store hydrates after first paint).
const noProject = page.locator('[data-testid="memory-view-no-project"]');
if (await noProject.isVisible().catch(() => false)) {
// Poll for the view to appear rather than a fixed timeout
await page
.locator('[data-testid="memory-view"], [data-testid="memory-view-loading"]')
.first()
.waitFor({ state: 'visible', timeout: 5000 })
.catch(() => {
throw new Error(
'Memory view showed "No project selected". Ensure setupProjectWithFixture runs before navigateToMemory and store has time to hydrate.'
);
});
}
// Wait for loading to complete (if present)
const loadingElement = page.locator('[data-testid="memory-view-loading"]');
if (await loadingElement.isVisible().catch(() => false)) {
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
}
// Wait for the memory view to be visible
await waitForElement(page, 'memory-view', { timeout: 15000 });
// On mobile, close the sidebar if open so the header actions trigger is clickable (not covered by backdrop)
// Use JavaScript click to avoid force:true hitting the sidebar (z-30) instead of the backdrop (z-20)
const backdrop = page.locator('[data-testid="sidebar-backdrop"]');
if (await backdrop.isVisible().catch(() => false)) {
await backdrop.evaluate((el) => (el as HTMLElement).click());
}
// Dismiss any open dialog that may block interactions (e.g. sandbox warning, onboarding).
// The sandbox dialog blocks Escape, so click "I Accept the Risks" if it becomes visible within 1s.
const sandboxAcceptBtn = page.locator('button:has-text("I Accept the Risks")');
const sandboxVisible = await sandboxAcceptBtn
.waitFor({ state: 'visible', timeout: 1000 })
.then(() => true)
.catch(() => false);
if (sandboxVisible) {
await sandboxAcceptBtn.click();
await page
.locator('[role="dialog"][data-state="open"]')
.first()
.waitFor({ state: 'hidden', timeout: 3000 })
.catch(() => {});
} else {
await closeDialogWithEscape(page, { timeout: 2000 });
}
// Ensure the header (and actions panel trigger on mobile) is interactive
await page
.locator('[data-testid="header-actions-panel-trigger"]')
.waitFor({ state: 'visible', timeout: 5000 })
.catch(() => {});
}
|