mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
5a10426051
commit
50c9defb34
4 changed files with 329 additions and 4 deletions
320
gitnexus-web/e2e/directory-picker.spec.ts
Normal file
320
gitnexus-web/e2e/directory-picker.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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') });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue