Make memory and context views mobile-friendly (#813)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-26 03:31:40 -08:00
committed by GitHub
parent 6408f514a4
commit 583c3eb4a6
30 changed files with 3758 additions and 113 deletions

View File

@@ -1,6 +1,5 @@
import { Page, expect } from '@playwright/test';
import { getByTestId, getButtonByText } from './elements';
import { waitForSplashScreenToDisappear } from './waiting';
/**
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
@@ -23,10 +22,10 @@ export async function pressModifierEnter(page: Page): Promise<void> {
* Waits for the element to be visible before clicking to avoid flaky tests
*/
export async function clickElement(page: Page, testId: string): Promise<void> {
// Wait for splash screen to disappear first (safety net)
await waitForSplashScreenToDisappear(page, 5000);
// Splash screen waits are handled by navigation helpers (navigateToContext, navigateToMemory, etc.)
// before any clickElement calls, so we skip the splash check here to avoid blocking when
// other fixed overlays (e.g. HeaderActionsPanel backdrop at z-[60]) are present on the page.
const element = page.locator(`[data-testid="${testId}"]`);
// Wait for element to be visible and stable before clicking
await element.waitFor({ state: 'visible', timeout: 10000 });
await element.click();
}

View File

@@ -54,13 +54,16 @@ export async function waitForElementHidden(
*/
export async function waitForSplashScreenToDisappear(page: Page, timeout = 5000): Promise<void> {
try {
// Check if splash screen is shown via sessionStorage first (fastest check)
const splashShown = await page.evaluate(() => {
return sessionStorage.getItem('automaker-splash-shown') === 'true';
// Check if splash screen is disabled or already shown (fastest check)
const splashDisabled = await page.evaluate(() => {
return (
localStorage.getItem('automaker-disable-splash') === 'true' ||
localStorage.getItem('automaker-splash-shown-session') === 'true'
);
});
// If splash is already marked as shown, it won't appear, so we're done
if (splashShown) {
// If splash is disabled or already shown, it won't appear, so we're done
if (splashDisabled) {
return;
}
@@ -69,8 +72,11 @@ export async function waitForSplashScreenToDisappear(page: Page, timeout = 5000)
// We check for elements that match the splash screen pattern
await page.waitForFunction(
() => {
// Check if splash is marked as shown in sessionStorage
if (sessionStorage.getItem('automaker-splash-shown') === 'true') {
// Check if splash is disabled or already shown
if (
localStorage.getItem('automaker-disable-splash') === 'true' ||
localStorage.getItem('automaker-splash-shown-session') === 'true'
) {
return true;
}

View File

@@ -381,7 +381,7 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, projectPath);
}
@@ -435,7 +435,7 @@ export async function setupProjectWithPathNoWorktrees(
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, projectPath);
}
@@ -493,7 +493,7 @@ export async function setupProjectWithStaleWorktree(
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, projectPath);
}

View File

@@ -22,10 +22,12 @@ export * from './navigation/views';
// View-specific utilities
export * from './views/board';
export * from './views/context';
export * from './views/memory';
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';

View File

@@ -12,9 +12,12 @@ export async function navigateToBoard(page: Page): Promise<void> {
// Authenticate before navigating
await authenticateForTests(page);
// Wait for any pending navigation to complete before starting a new one
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(100);
// Navigate directly to /board route
await page.goto('/board');
await page.waitForLoadState('load');
await page.goto('/board', { waitUntil: 'domcontentloaded' });
// Wait for splash screen to disappear (safety net)
await waitForSplashScreenToDisappear(page, 3000);
@@ -34,9 +37,13 @@ export async function navigateToContext(page: Page): Promise<void> {
// Authenticate before navigating
await authenticateForTests(page);
// Wait for any pending navigation to complete before starting a new one
// This prevents race conditions, especially on mobile viewports
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(100);
// Navigate directly to /context route
await page.goto('/context');
await page.waitForLoadState('load');
await page.goto('/context', { waitUntil: 'domcontentloaded' });
// Wait for splash screen to disappear (safety net)
await waitForSplashScreenToDisappear(page, 3000);
@@ -59,6 +66,14 @@ export async function navigateToContext(page: Page): Promise<void> {
// Wait for the context view to be visible
// Increase timeout to handle slower server startup
await waitForElement(page, 'context-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());
await page.waitForTimeout(200);
}
}
/**
@@ -69,9 +84,12 @@ export async function navigateToSpec(page: Page): Promise<void> {
// Authenticate before navigating
await authenticateForTests(page);
// Wait for any pending navigation to complete before starting a new one
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(100);
// Navigate directly to /spec route
await page.goto('/spec');
await page.waitForLoadState('load');
await page.goto('/spec', { waitUntil: 'domcontentloaded' });
// Wait for splash screen to disappear (safety net)
await waitForSplashScreenToDisappear(page, 3000);
@@ -105,9 +123,12 @@ export async function navigateToAgent(page: Page): Promise<void> {
// Authenticate before navigating
await authenticateForTests(page);
// Wait for any pending navigation to complete before starting a new one
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(100);
// Navigate directly to /agent route
await page.goto('/agent');
await page.waitForLoadState('load');
await page.goto('/agent', { waitUntil: 'domcontentloaded' });
// Wait for splash screen to disappear (safety net)
await waitForSplashScreenToDisappear(page, 3000);

View File

@@ -0,0 +1,180 @@
/**
* Tests for project fixture utilities
*
* Tests for path traversal guard and file operations in test fixtures
*/
import { test, expect } from '@playwright/test';
import {
createMemoryFileOnDisk,
memoryFileExistsOnDisk,
resetMemoryDirectory,
createContextFileOnDisk,
contextFileExistsOnDisk,
resetContextDirectory,
} from './fixtures';
test.describe('Memory Fixture Utilities', () => {
test.beforeEach(() => {
resetMemoryDirectory();
});
test.afterEach(() => {
resetMemoryDirectory();
});
test('should create and detect a valid memory file', () => {
const filename = 'test-file.md';
const content = '# Test Content';
createMemoryFileOnDisk(filename, content);
expect(memoryFileExistsOnDisk(filename)).toBe(true);
});
test('should return false for non-existent file', () => {
expect(memoryFileExistsOnDisk('non-existent.md')).toBe(false);
});
test('should reject path traversal attempt with ../', () => {
const maliciousFilename = '../../../etc/passwd';
expect(() => {
createMemoryFileOnDisk(maliciousFilename, 'malicious content');
}).toThrow('Invalid memory filename');
expect(() => {
memoryFileExistsOnDisk(maliciousFilename);
}).toThrow('Invalid memory filename');
});
test('should handle Windows-style path traversal attempt ..\\ (platform-dependent)', () => {
const maliciousFilename = '..\\..\\..\\windows\\system32\\config';
// On Unix/macOS, backslash is treated as a literal character in filenames,
// not as a path separator, so path.resolve doesn't traverse directories.
// This test documents that behavior - the guard works for Unix paths,
// but Windows-style backslashes are handled differently per platform.
// On macOS/Linux: backslash is a valid filename character
// On Windows: would need additional normalization to prevent traversal
expect(() => {
memoryFileExistsOnDisk(maliciousFilename);
}).not.toThrow();
// The file gets created with backslashes in the name (which is valid on Unix)
// but won't escape the directory
});
test('should reject absolute path attempt', () => {
const maliciousFilename = '/etc/passwd';
expect(() => {
createMemoryFileOnDisk(maliciousFilename, 'malicious content');
}).toThrow('Invalid memory filename');
expect(() => {
memoryFileExistsOnDisk(maliciousFilename);
}).toThrow('Invalid memory filename');
});
test('should accept nested paths within memory directory', () => {
// Note: This tests the boundary - if subdirectories are supported,
// this should pass; if not, it should throw
const nestedFilename = 'subfolder/nested-file.md';
// Currently, the implementation doesn't create subdirectories,
// so this would fail when trying to write. But the path itself
// is valid (doesn't escape the memory directory)
expect(() => {
memoryFileExistsOnDisk(nestedFilename);
}).not.toThrow();
});
test('should handle filenames without extensions', () => {
const filename = 'README';
createMemoryFileOnDisk(filename, 'content without extension');
expect(memoryFileExistsOnDisk(filename)).toBe(true);
});
test('should handle filenames with multiple dots', () => {
const filename = 'my.file.name.md';
createMemoryFileOnDisk(filename, '# Multiple dots');
expect(memoryFileExistsOnDisk(filename)).toBe(true);
});
});
test.describe('Context Fixture Utilities', () => {
test.beforeEach(() => {
resetContextDirectory();
});
test.afterEach(() => {
resetContextDirectory();
});
test('should create and detect a valid context file', () => {
const filename = 'test-context.md';
const content = '# Test Context Content';
createContextFileOnDisk(filename, content);
expect(contextFileExistsOnDisk(filename)).toBe(true);
});
test('should return false for non-existent context file', () => {
expect(contextFileExistsOnDisk('non-existent.md')).toBe(false);
});
test('should reject path traversal attempt with ../ for context files', () => {
const maliciousFilename = '../../../etc/passwd';
expect(() => {
createContextFileOnDisk(maliciousFilename, 'malicious content');
}).toThrow('Invalid context filename');
expect(() => {
contextFileExistsOnDisk(maliciousFilename);
}).toThrow('Invalid context filename');
});
test('should reject absolute path attempt for context files', () => {
const maliciousFilename = '/etc/passwd';
expect(() => {
createContextFileOnDisk(maliciousFilename, 'malicious content');
}).toThrow('Invalid context filename');
expect(() => {
contextFileExistsOnDisk(maliciousFilename);
}).toThrow('Invalid context filename');
});
test('should accept nested paths within context directory', () => {
const nestedFilename = 'subfolder/nested-file.md';
// The path itself is valid (doesn't escape the context directory)
expect(() => {
contextFileExistsOnDisk(nestedFilename);
}).not.toThrow();
});
test('should handle filenames without extensions for context', () => {
const filename = 'README';
createContextFileOnDisk(filename, 'content without extension');
expect(contextFileExistsOnDisk(filename)).toBe(true);
});
test('should handle filenames with multiple dots for context', () => {
const filename = 'my.context.file.md';
createContextFileOnDisk(filename, '# Multiple dots');
expect(contextFileExistsOnDisk(filename)).toBe(true);
});
});

View File

@@ -17,6 +17,7 @@ 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');
const MEMORY_PATH = path.join(FIXTURE_PATH, '.automaker/memory');
// Original spec content for resetting between tests
const ORIGINAL_SPEC_CONTENT = `<app_spec>
@@ -50,11 +51,53 @@ export function resetContextDirectory(): void {
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
}
/**
* Reset the memory directory to empty state
*/
export function resetMemoryDirectory(): void {
if (fs.existsSync(MEMORY_PATH)) {
fs.rmSync(MEMORY_PATH, { recursive: true });
}
fs.mkdirSync(MEMORY_PATH, { recursive: true });
}
/**
* Resolve and validate a context fixture path to prevent path traversal
*/
function resolveContextFixturePath(filename: string): string {
const resolved = path.resolve(CONTEXT_PATH, filename);
const base = path.resolve(CONTEXT_PATH) + path.sep;
if (!resolved.startsWith(base)) {
throw new Error(`Invalid context filename: ${filename}`);
}
return resolved;
}
/**
* 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);
const filePath = resolveContextFixturePath(filename);
fs.writeFileSync(filePath, content);
}
/**
* Resolve and validate a memory fixture path to prevent path traversal
*/
function resolveMemoryFixturePath(filename: string): string {
const resolved = path.resolve(MEMORY_PATH, filename);
const base = path.resolve(MEMORY_PATH) + path.sep;
if (!resolved.startsWith(base)) {
throw new Error(`Invalid memory filename: ${filename}`);
}
return resolved;
}
/**
* Create a memory file directly on disk (for test setup)
*/
export function createMemoryFileOnDisk(filename: string, content: string): void {
const filePath = resolveMemoryFixturePath(filename);
fs.writeFileSync(filePath, content);
}
@@ -62,7 +105,15 @@ export function createContextFileOnDisk(filename: string, content: string): void
* Check if a context file exists on disk
*/
export function contextFileExistsOnDisk(filename: string): boolean {
const filePath = path.join(CONTEXT_PATH, filename);
const filePath = resolveContextFixturePath(filename);
return fs.existsSync(filePath);
}
/**
* Check if a memory file exists on disk
*/
export function memoryFileExistsOnDisk(filename: string): boolean {
const filePath = resolveMemoryFixturePath(filename);
return fs.existsSync(filePath);
}
@@ -112,8 +163,29 @@ export async function setupProjectWithFixture(
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Set settings cache so the fast-hydrate path uses our fixture project.
// Without this, a stale settings cache from a previous test can override
// the project we just set in automaker-storage.
const settingsCache = {
setupComplete: true,
isFirstRun: false,
projects: [
{
id: mockProject.id,
name: mockProject.name,
path: mockProject.path,
lastOpened: mockProject.lastOpened,
},
],
currentProjectId: mockProject.id,
theme: 'dark',
sidebarOpen: true,
maxConcurrency: 3,
};
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, projectPath);
}
@@ -123,3 +195,14 @@ export async function setupProjectWithFixture(
export function getFixturePath(): string {
return FIXTURE_PATH;
}
/**
* Set up a mock project with the fixture path (for profile/settings tests that need a project).
* Options such as customProfilesCount are reserved for future use (e.g. mocking server profile state).
*/
export async function setupMockProjectWithProfiles(
page: Page,
_options?: { customProfilesCount?: number }
): Promise<void> {
await setupProjectWithFixture(page, FIXTURE_PATH);
}

View File

@@ -84,6 +84,9 @@ export async function setupWelcomeView(
setupComplete: true,
isFirstRun: false,
projects: opts?.recentProjects || [],
// Explicitly set currentProjectId to null so the fast-hydrate path
// does not restore a stale project from a previous test.
currentProjectId: null,
theme: 'dark',
sidebarOpen: true,
maxConcurrency: 3,
@@ -103,7 +106,7 @@ export async function setupWelcomeView(
}
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
// Set up a mechanism to keep currentProject null even after settings hydration
// Settings API might restore a project, so we override it after hydration
@@ -226,7 +229,7 @@ export async function setupRealProject(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
},
{ path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS }
);
@@ -291,7 +294,7 @@ export async function setupMockProject(page: Page): Promise<void> {
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, STORE_VERSIONS);
}
@@ -423,7 +426,7 @@ export async function setupMockProjectAtConcurrencyLimit(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
},
{ maxConcurrency, runningTasks, versions: STORE_VERSIONS }
);
@@ -505,7 +508,7 @@ export async function setupMockProjectWithFeatures(
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
},
{ opts: options, versions: STORE_VERSIONS }
);
@@ -577,7 +580,7 @@ export async function setupMockProjectWithContextFile(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
// Set up mock file system with a context file for the feature
// This will be used by the mock electron API
@@ -769,7 +772,7 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, STORE_VERSIONS);
}
@@ -832,7 +835,7 @@ export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void>
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, STORE_VERSIONS);
}
@@ -910,7 +913,7 @@ export async function setupMockProjectWithSkipTestsFeatures(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
},
{ opts: options, versions: STORE_VERSIONS }
);
@@ -985,7 +988,7 @@ export async function setupMockMultipleProjects(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
},
{ count: projectCount, versions: STORE_VERSIONS }
);
@@ -1056,7 +1059,7 @@ export async function setupMockProjectWithAgentOutput(
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
// Set up mock file system with output content for the feature
// Now uses features/{id}/agent-output.md path
@@ -1215,7 +1218,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, STORE_VERSIONS);
}
@@ -1238,6 +1241,6 @@ export async function setupComplete(page: Page): Promise<void> {
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
localStorage.setItem('automaker-disable-splash', 'true');
}, STORE_VERSIONS);
}

View File

@@ -97,10 +97,22 @@ export async function deleteSelectedContextFile(page: Page): Promise<void> {
*/
export async function saveContextFile(page: Page): Promise<void> {
await clickElement(page, 'save-context-file');
// Wait for save to complete (button shows "Saved")
// 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(
() =>
document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'),
() => {
const btn = document.querySelector('[data-testid="save-context-file"]');
if (!btn) return false;
const stateText = [
btn.textContent ?? '',
btn.getAttribute('aria-label') ?? '',
btn.getAttribute('title') ?? '',
]
.join(' ')
.toLowerCase();
return stateText.includes('saved');
},
{ timeout: 5000 }
);
}
@@ -138,13 +150,16 @@ export async function selectContextFile(
): Promise<void> {
const fileButton = await getByTestId(page, `context-file-${filename}`);
// Retry click + wait for delete button to handle timing issues
// Retry click + wait for content panel to handle timing issues
// Note: On mobile, delete button is hidden, so we wait for content panel instead
await expect(async () => {
// 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();
// Wait for content to appear (editor, preview, or image)
const contentLocator = page.locator(
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]'
);
await expect(contentLocator).toBeVisible();
}).toPass({ timeout, intervals: [500, 1000, 2000] });
}

View File

@@ -0,0 +1,238 @@
import { Page, Locator } from '@playwright/test';
import { clickElement, fillInput, handleLoginScreenIfPresent } from '../core/interactions';
import {
waitForElement,
waitForElementHidden,
waitForSplashScreenToDisappear,
} 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: [500, 1000, 2000] });
}
/**
* 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
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();
}).toPass({ timeout, intervals: [500, 1000, 2000] });
}
/**
* 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> {
await expect(async () => {
const contentLocator = page.locator(
'[data-testid="memory-editor"], [data-testid="markdown-preview"]'
);
await expect(contentLocator).toBeVisible();
}).toPass({ timeout, intervals: [500, 1000, 2000] });
}
/**
* 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,
});
}
}
/**
* 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 (same pattern as navigateToContext / navigateToBoard)
await authenticateForTests(page);
// Wait for any pending navigation to complete before starting a new one
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(100);
// Navigate directly to /memory route
await page.goto('/memory', { waitUntil: 'domcontentloaded' });
// Wait for splash screen to disappear (safety net)
await waitForSplashScreenToDisappear(page, 3000);
// Handle login redirect if needed (e.g. when redirected to /logged-out)
await handleLoginScreenIfPresent(page);
// Wait for loading to complete (if present)
const loadingElement = page.locator('[data-testid="memory-view-loading"]');
try {
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
if (loadingVisible) {
// Wait for loading to disappear (memory view will appear)
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
}
} catch {
// Loading element not found or already hidden, continue
}
// 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());
await page.waitForTimeout(200);
}
// 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(() => {});
}

View File

@@ -0,0 +1,522 @@
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');
}