test(e2e): add Playwright tests for server-side directory picker

13 Playwright e2e tests covering the full DirectoryPicker flow:
- Open/display: modal opens, shows root dirs, displays current path
- Navigation: click into dirs, breadcrumb back-nav, home button
- Selection: populates path input, returns absolute path, close without selecting
- Edge cases: empty dir, API error, manual typing still works

Also updates existing onboarding.spec.ts to match the renamed
"Browse server directories" button, and adds data-testid attributes
to DirectoryPicker and RepoAnalyzer for reliable e2e targeting.
This commit is contained in:
Gergo Magyar 2026-05-27 08:30:22 +01:00
parent 5a10426051
commit 50c9defb34
4 changed files with 329 additions and 4 deletions

View file

@ -0,0 +1,320 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for the server-side directory picker (issue #1518).
*
* All tests mock the backend at the network level so they don't
* require a live gitnexus server. The /api/fs/list endpoint is
* intercepted to return controlled directory structures.
*/
const BACKEND_URL = 'http://localhost:4747';
/** Standard backend mocks needed to reach the analyze form. */
async function mockBackendForAnalyzeForm(page: import('@playwright/test').Page) {
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
await page.route(`${BACKEND_URL}/api/info`, (route) =>
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
}
/** Navigate to the Local Folder tab in the analyze form. */
async function openLocalFolderTab(page: import('@playwright/test').Page) {
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
await page.getByRole('tab', { name: 'Local Folder' }).click();
}
// ── Directory picker: open and display ────────────────────────────────────
test.describe('Directory picker — open and display', () => {
test.beforeEach(async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await page.route(`${BACKEND_URL}/api/fs/list*`, (route) => {
const url = new URL(route.request().url());
const dir = url.searchParams.get('dir') ?? '/';
if (dir === '/') {
return route.fulfill({
json: { entries: [{ name: 'workspace' }, { name: 'data' }, { name: 'home' }] },
});
}
if (dir === '/workspace') {
return route.fulfill({
json: { entries: [{ name: 'my-project' }, { name: 'another-repo' }] },
});
}
if (dir === '/workspace/my-project') {
return route.fulfill({ json: { entries: [] } });
}
return route.fulfill({ json: { entries: [] } });
});
});
test('clicking Browse opens the directory picker modal', async ({ page }, testInfo) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await expect(page.locator('[data-testid="directory-picker-modal"]')).toBeVisible({
timeout: 5_000,
});
await page.screenshot({ path: testInfo.outputPath('picker-open.png') });
});
test('picker shows root directories from /api/fs/list', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await expect(page.locator('[data-testid="dir-entry-workspace"]')).toBeVisible({
timeout: 5_000,
});
await expect(page.locator('[data-testid="dir-entry-data"]')).toBeVisible();
await expect(page.locator('[data-testid="dir-entry-home"]')).toBeVisible();
});
test('current path shows / at root', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/');
});
});
// ── Directory picker: navigation ──────────────────────────────────────────
test.describe('Directory picker — navigation', () => {
test.beforeEach(async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await page.route(`${BACKEND_URL}/api/fs/list*`, (route) => {
const url = new URL(route.request().url());
const dir = url.searchParams.get('dir') ?? '/';
if (dir === '/') {
return route.fulfill({
json: { entries: [{ name: 'workspace' }, { name: 'data' }] },
});
}
if (dir === '/workspace') {
return route.fulfill({
json: { entries: [{ name: 'my-project' }, { name: 'another-repo' }] },
});
}
if (dir === '/workspace/my-project') {
return route.fulfill({ json: { entries: [{ name: 'src' }] } });
}
return route.fulfill({ json: { entries: [] } });
});
});
test('clicking a directory navigates into it', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="dir-entry-my-project"]')).toBeVisible({
timeout: 5_000,
});
await expect(page.locator('[data-testid="dir-entry-another-repo"]')).toBeVisible();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/workspace');
});
test('breadcrumb shows path segments after navigation', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="dir-entry-my-project"]')).toBeVisible({
timeout: 5_000,
});
// Breadcrumb should show "workspace" segment as a clickable button
const modal = page.locator('[data-testid="directory-picker-modal"]');
await expect(modal.getByRole('button', { name: 'workspace', exact: true })).toBeVisible();
});
test('clicking breadcrumb segment navigates back', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
// Navigate: / → /workspace → /workspace/my-project
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="dir-entry-my-project"]')).toBeVisible({
timeout: 5_000,
});
await page.locator('[data-testid="dir-entry-my-project"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText(
'/workspace/my-project',
{ timeout: 5_000 },
);
// Click breadcrumb "workspace" to go back to /workspace
const modal = page.locator('[data-testid="directory-picker-modal"]');
const breadcrumbSegments = modal.locator('button').filter({ hasText: 'workspace' });
await breadcrumbSegments.first().click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/workspace', {
timeout: 5_000,
});
await expect(page.locator('[data-testid="dir-entry-my-project"]')).toBeVisible();
});
test('home button navigates to root', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
// Navigate into /workspace
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/workspace', {
timeout: 5_000,
});
// Click home icon
await page.locator('[data-testid="directory-picker-home"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/', {
timeout: 5_000,
});
await expect(page.locator('[data-testid="dir-entry-workspace"]')).toBeVisible();
});
});
// ── Directory picker: selection ───────────────────────────────────────────
test.describe('Directory picker — selection', () => {
test.beforeEach(async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await page.route(`${BACKEND_URL}/api/fs/list*`, (route) => {
const url = new URL(route.request().url());
const dir = url.searchParams.get('dir') ?? '/';
if (dir === '/') {
return route.fulfill({ json: { entries: [{ name: 'workspace' }] } });
}
if (dir === '/workspace') {
return route.fulfill({ json: { entries: [{ name: 'my-project' }] } });
}
return route.fulfill({ json: { entries: [] } });
});
});
test('selecting a folder populates the local path input', async ({ page }, testInfo) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
// Navigate to /workspace
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText('/workspace', {
timeout: 5_000,
});
// Click "Select this folder"
await page.locator('[data-testid="directory-picker-select"]').click();
// Modal should close
await expect(page.locator('[data-testid="directory-picker-modal"]')).not.toBeVisible();
// The local path input should contain the selected path
const pathInput = page.locator('input[type="text"]');
await expect(pathInput).toHaveValue('/workspace');
await page.screenshot({ path: testInfo.outputPath('path-populated.png') });
});
test('selected path is an absolute path', async ({ page }) => {
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
await page.locator('[data-testid="dir-entry-workspace"]').click();
await expect(page.locator('[data-testid="dir-entry-my-project"]')).toBeVisible({
timeout: 5_000,
});
await page.locator('[data-testid="dir-entry-my-project"]').click();
await expect(page.locator('[data-testid="directory-picker-path"]')).toHaveText(
'/workspace/my-project',
{ timeout: 5_000 },
);
await page.locator('[data-testid="directory-picker-select"]').click();
const pathInput = page.locator('input[type="text"]');
await expect(pathInput).toHaveValue('/workspace/my-project');
});
test('closing the modal does not change the path input', async ({ page }) => {
await openLocalFolderTab(page);
// Type a path manually first
const pathInput = page.locator('input[type="text"]');
await pathInput.fill('/my/custom/path');
// Open and close the picker without selecting
await page.locator('[data-testid="browse-server-dirs"]').click();
await expect(page.locator('[data-testid="directory-picker-modal"]')).toBeVisible({
timeout: 5_000,
});
// Click the backdrop to close
await page.locator('[data-testid="directory-picker-modal"]').locator('..').locator('div').first().click({ position: { x: 5, y: 5 }, force: true });
// Path input should keep the original value
await expect(pathInput).toHaveValue('/my/custom/path');
});
});
// ── Directory picker: edge cases ──────────────────────────────────────────
test.describe('Directory picker — edge cases', () => {
test('shows empty state for a directory with no subdirectories', async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await page.route(`${BACKEND_URL}/api/fs/list*`, (route) =>
route.fulfill({ json: { entries: [] } }),
);
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
const modal = page.locator('[data-testid="directory-picker-modal"]');
await expect(modal.getByText('This directory is empty.')).toBeVisible({ timeout: 5_000 });
});
test('shows error when /api/fs/list fails', async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await page.route(`${BACKEND_URL}/api/fs/list*`, (route) =>
route.fulfill({ status: 500, json: { error: 'Internal server error' } }),
);
await openLocalFolderTab(page);
await page.locator('[data-testid="browse-server-dirs"]').click();
const modal = page.locator('[data-testid="directory-picker-modal"]');
await expect(modal.locator('text=Go back')).toBeVisible({ timeout: 5_000 });
});
test('manual path typing still works without opening picker', async ({ page }) => {
await mockBackendForAnalyzeForm(page);
await openLocalFolderTab(page);
// Type a path manually
const pathInput = page.locator('input[type="text"]');
await pathInput.fill('/workspace/my-repo');
// The input should have the typed path
await expect(pathInput).toHaveValue('/workspace/my-repo');
// The Analyze button should be enabled
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
await expect(analyzeBtn).toBeEnabled();
});
});

View file

@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
// Switch to Local Folder tab
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Browse button should be visible
await expect(page.getByText('Browse for folder')).toBeVisible();
// Browse button should be visible (server-side directory picker)
await expect(page.locator('[data-testid="browse-server-dirs"]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
});

View file

@ -62,6 +62,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
/>
<div
data-testid="directory-picker-modal"
className={`relative mx-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
style={{ maxHeight: '70vh' }}
>
@ -81,6 +82,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
{/* Breadcrumb */}
<div className="flex items-center gap-1 overflow-x-auto border-b border-border-subtle bg-elevated/50 px-5 py-2.5">
<button
data-testid="directory-picker-home"
onClick={() => navigateTo('/')}
className="shrink-0 rounded p-0.5 text-text-muted transition-colors hover:text-accent"
>
@ -104,7 +106,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
</div>
{/* Directory listing */}
<div className="min-h-[200px] flex-1 overflow-y-auto px-2 py-2">
<div data-testid="directory-listing" className="min-h-[200px] flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-text-muted" />
@ -140,6 +142,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
return (
<button
key={entry.name}
data-testid={`dir-entry-${entry.name}`}
onClick={() => navigateTo(target)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-colors hover:bg-hover"
>
@ -155,10 +158,11 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
{/* Footer — current path + select button */}
<div className="flex items-center gap-3 border-t border-border-subtle bg-elevated/30 px-5 py-3">
<code className="min-w-0 flex-1 truncate rounded bg-void px-2.5 py-1.5 font-mono text-xs text-text-secondary">
<code data-testid="directory-picker-path" className="min-w-0 flex-1 truncate rounded bg-void px-2.5 py-1.5 font-mono text-xs text-text-secondary">
{currentDir}
</code>
<button
data-testid="directory-picker-select"
onClick={() => onSelect(currentDir)}
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-xs font-medium text-white transition-colors hover:bg-accent/90"
>

View file

@ -446,6 +446,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
<button
type="button"
data-testid="browse-server-dirs"
onClick={() => setPickerOpen(true)}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"