GitNexus/gitnexus-web/e2e/onboarding.spec.ts
Gergő Magyar 6424d8b09c
fix(web): replace broken Browse-for-folder with upload directory picker (#1850)
* fix(web): replace broken Browse-for-folder with server-side directory picker

The "Browse for folder" button used `<input type="file" webkitdirectory>`
which only exposes relative paths via `webkitRelativePath`. The code
extracted just the folder name (e.g. `myproject`), causing the server to
reject it with "path must be an absolute path". No browser API can
expose absolute filesystem paths, so the approach was fundamentally
broken on all platforms.

- Add `GET /api/fs/list` endpoint that lists subdirectories at a given
  absolute server-side path (rate-limited, validated)
- Add `listDirectories()` client function in backend-client.ts
- Add `DirectoryPicker` modal component with breadcrumb navigation
- Replace broken `webkitdirectory` input in RepoAnalyzer with the new
  server-side directory picker
- Update i18n strings (en + zh-CN)
- Add unit tests for the new endpoint (9 tests)

Docker users can now browse `/workspace/` and other container paths
directly from the UI. Manual path entry continues to work unchanged.

Closes #1518

* 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.

* fix(a11y): add accessibility and UX polish to DirectoryPicker

- Add role="dialog", aria-modal, aria-label to the modal panel
- Add aria-label to close button, home button
- Add aria-hidden to decorative icons (chevrons, backdrop)
- Add role="status" to loading spinner with sr-only label
- Add role="alert" to error state
- Add aria-current="location" to active breadcrumb segment
- Wrap breadcrumb in nav landmark with aria-label
- Add Escape key handler to dismiss the modal
- Auto-focus the modal panel on open
- Add focus-visible ring styles to all interactive elements
  (matches existing focus-visible:ring-2 ring-accent/40 pattern)
- Increase breadcrumb button padding (px-1.5 py-1) for better
  touch targets
- Increase directory entry padding (py-2.5) for touch comfort
- Add active:bg-hover/70 pressed state on directory entries
- Add active:bg-accent/80 pressed state on select button

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: skip traversal guard for bare root paths in /api/fs/list (#2109)

* fix(web): replace server-side directory picker with secure folder upload

PR #1850 review found the new GET /api/fs/list directory-browsing endpoint
enumerated any absolute server path (CodeQL js/path-injection, plus a DoS and
cross-origin enumeration via the CORS/PNA allow-list). Browsers can't hand the
server an absolute path, so rather than harden the endpoint, remove it and
upload the folder instead — webkitdirectory exposes the file contents.

- Add POST /api/analyze/upload: busboy-streamed multipart ingest into an
  mkdtemp sandbox under UPLOAD_ROOT with resolve-then-contain write
  sanitization, hard size/count/dir caps, manifest-first ordering, and
  guaranteed cleanup; promote (atomic same-filesystem rename, no EXDEV) and
  analyze via the shared job/worker machinery, never returning a server path.
- Frontend: <input webkitdirectory> upload flow with client-side filtering
  (.git/node_modules/build), XHR progress, accessibility, en/zh-CN i18n.
- Remove /api/fs/list + handleFsListRequest, DirectoryPicker, listDirectories
  and their tests.
- Harden the adjacent /api/analyze {path} route: localhost-only CORS on write
  routes + realpath/exists/isDir validation replacing the inert
  normalize!==resolve guard.
- Extend DELETE /api/repo cleanup to upload dirs (by entry.path) and add a
  startup sweep for orphaned staging dirs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): resolve CodeQL path-injection + CSRF introduced by the upload change

The first push surfaced two new CodeQL alerts in the newly-added code (the
upload sandbox itself passed — its resolve-then-contain sanitizer is recognized):

- HIGH js/path-injection at the analyze route: the KTD11 in-route
  `fs.realpath(repoLocalPath)` / `fs.stat` was a user-controlled filesystem
  read with no security gain (the worker already reads the path; cross-origin
  reach is closed by requireLocalhostOrigin). Drop the in-route fs calls; keep
  only the absolute-path check + the localhost-origin guard.
- MEDIUM js/client-side-request-forgery: the new raw `xhr.open` was a fresh
  request sink. Route the upload through the shared, origin-validated
  fetchWithTimeout instead (the centralized sink all other calls use). Trades
  the upload-progress percentage for an indeterminate "Uploading…" state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): resolve tri-review findings on the upload flow

A multi-agent review of the upload implementation surfaced a P0 plus several
P2/P3s; all are addressed here.

- P0: the upload handler took the single analysis slot (createJob) before
  validating/promoting, so any failure in that window left a queued job that
  was never failed — wedging ALL analysis until restart (trivially triggered by
  a single-segment manifest). Now: validate the folder before taking the slot,
  release it via failJob on any pre-launch error, and reject single-segment /
  multi-top manifests during ingest (also fixes a silent file-drop).
- CI: rate-limit.test's source-regex broke when Prettier wrapped the
  /api/analyze registration; made it wrapping-tolerant.
- Resource: the startup sweep now also removes stale promoted upload dirs with
  no .gitnexus index (orphans from analyses that failed before registering).
- Frontend: guard against post-unmount SSE opening, reset upload state on
  cancel/mode-change, guard concurrent uploads, fall back to the folder name,
  add aria-busy, and fix the {{count}} plural ("1 files").
- Maintainability: extract launchAnalysisWorker into analyze-launch.ts (DI +
  typed WorkerMessage IPC), move requireLocalhostOrigin to middleware.ts, share
  REPO_NAME_PATTERN, tighten UploadJobRef, name the collision-retry constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): reset isMountedRef on mount (StrictMode double-invoke)

The mount effect set isMountedRef=false on cleanup but never back to true on
re-mount, so under React StrictMode's mount->unmount->mount the ref stayed
false for the component's lifetime — trackJob then always early-returned and
the upload never advanced past 'starting' (caught by the folder-upload e2e).
Set it true at the start of the effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): de-flake upload-ingest cleanup test via injectable staging root

ingestUpload gains an IngestOptions.root override (mirroring SweepOptions.root)
so the test asserts cleanup against a per-test mkdtemp root instead of counting
global ~/.gitnexus/uploads/.staging-* entries, which raced parallel forks.
Production default stays UPLOAD_ROOT (promote rename same-filesystem invariant).

* fix(web): make stale analyze/upload requests inert after mode switch, cancel, or unmount

A folder upload (or URL analyze) still in flight when the user switched modes
could resolve later, call trackJob(), and drive the old job's SSE stream under
the new mode's form. The only guard was isMountedRef — mode change and cancel
never unmount the component.

- requestControllerRef: per-request AbortController doubling as the staleness
  token (captured per closure, checked after the await; the abort error is
  matched via signal.aborted, never error identity, since it surfaces both as
  BackendError('Request aborted') and as a raw AbortError from response.json())
- uploadFolder() now takes an optional AbortSignal; fetchWithTimeout already
  merges caller signals via AbortSignal.any
- a stale-but-created job gets a fire-and-forget cancelAnalyze(jobId) (skipped
  when a live tracking session owns the id) so the single analyze slot is freed
- handleModeChange early-returns on same-tab clicks and resets phase to input
  so an aborted request can't strand the form at 'starting'
- fixed the stale breaker comment: resilientFetch records AbortError as
  breaker-neutral (recordNeutral), not as a retryable-network penalty

* refactor(web): consolidate stale-request guard plumbing

- single invalidateRequest() helper for the abort+null pattern (4 sites)
- drop isMountedRef checks subsumed by the aborted-controller token
  (unmount aborts the controller, and unlike isMountedRef the token stays
  correct across a StrictMode unmount/remount)
- dedup the component test's render/mock scaffolding
- countStaging filters on the exported STAGING_PREFIX, not a magic string

* fix(web): scope stale-job cancellation to the upload path

Code review caught a regression in the first cut: URL analyzes dedup-alias by
repo (createJob returns the existing active job's id), so a stale resolution's
fire-and-forget cancel could kill a job another session — or the user's own
fresh resubmit — is actively watching; the jobIdRef ownership guard was
order-dependent and instance-local. Uploads always own a fresh, never-deduped
job, so the cancel is kept (unconditionally) there and dropped on the URL path,
where a same-URL resubmit re-attaches via dedup and the server's job timeout /
TTL sweep bounds the slot occupancy.

Also: remove the isMountedRef machinery outright (zero readers remain — the
aborted-controller token subsumes it and stays correct across StrictMode
remounts), make the e2e abort check ERR_ABORTED-specific, and let a broken
test root fail loudly instead of passing vacuously.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Sparsh <73558748+prajapatisparsh@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:50:59 +01:00

307 lines
12 KiB
TypeScript

import { test, expect } from '@playwright/test';
/**
* E2E tests for the onboarding and analysis user flows.
*
* These tests cover:
* - Flow 1: OnboardingGuide shown when no server is running
* - Flow 2: Analyze form when server has zero repos
* - Flow 3: Auto-connect when server has repos
* - Flow 4: Repo dropdown in exploring view
*
* Most tests mock the backend at the network level so they don't
* require a live gitnexus server.
*/
const BACKEND_URL = 'http://localhost:4747';
async function enterExploringView(page: import('@playwright/test').Page) {
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
// Match the 45s budget used by waitForGraphLoaded() in
// server-connect.spec.ts; under parallel CI workers, downloading the full
// graph can occasionally exceed 30s.
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 });
}
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
test.describe('Flow 1: Onboarding — no server', () => {
test('shows OnboardingGuide when backend is unreachable', async ({ page }, testInfo) => {
// Block all requests to the backend so the probe fails
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Wait for initial probe to complete and onboarding to appear
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: testInfo.outputPath('onboarding-visible.png') });
});
test('shows step-by-step instructions', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Step 1 is active (done once polling starts)
await expect(page.getByText('Copy the command')).toBeAttached({ timeout: 10_000 });
// Step 2 title changes to "Waiting for server to start" once polling begins
await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 });
// Step 3 is always rendered
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({
timeout: 5_000,
});
});
test('shows terminal window with command', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Should show either dev or prod command in a terminal block
const terminal = page.locator('code');
await expect(terminal.first()).toBeVisible({ timeout: 10_000 });
// The $ prompt should be present
await expect(page.getByText('$')).toBeVisible();
});
test('shows polling indicator', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Polling starts after initial probe fails
await expect(page.getByText('Listening for server')).toBeVisible({ timeout: 10_000 });
});
test('shows Node.js version requirement', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
await expect(page.getByText(/Node\.js.*\d+/)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Port 4747')).toBeVisible();
});
test('copy button has accessible label', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
await expect(page.getByText('Copy the command')).toBeVisible({ timeout: 10_000 });
const copyBtn = page.getByLabel('Copy to clipboard').first();
await expect(copyBtn).toBeVisible();
});
});
// ── Flow 2: Server detected → success → auto-connect ──────────────────────
test.describe('Flow 2: Server detected — auto-connect', () => {
test('shows success card when server becomes reachable', async ({ page }, testInfo) => {
// Start with server unreachable
let blockBackend = true;
await page.route(`${BACKEND_URL}/**`, (route) => {
if (blockBackend) return route.abort('connectionrefused');
// Let it through to the real handler below
return route.fallback();
});
// Mock the backend responses for when we "start" the server
await page.route(`${BACKEND_URL}/api/repos`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({ json: [{ name: 'test-repo', path: '/tmp/test' }] });
});
await page.route(`${BACKEND_URL}/api/repo`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({
json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' },
});
});
await page.route(`${BACKEND_URL}/api/graph**`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({ json: { nodes: [], relationships: [] } });
});
await page.route(`${BACKEND_URL}/api/heartbeat`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
// SSE response
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: ':ok\n\n',
});
});
await page.goto('/');
// Verify onboarding is shown first
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: testInfo.outputPath('before-server-start.png') });
// "Start" the server by unblocking requests
blockBackend = false;
// Wait for success card
await expect(page.getByText('Server Connected')).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('success-card.png') });
});
test('transitions to analyze phase when server has zero repos', async ({ page }, testInfo) => {
// Mock server with zero repos — repos endpoint returns empty array
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',
}),
);
await page.goto('/');
// Should transition: onboarding → success → analyze (zero repos)
// The analyze form tabs should be visible
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('analyze-empty-state.png') });
});
});
// ── Flow 3: Analyze form ───────────────────────────────────────────────────
test.describe('Flow 3: Analyze form', () => {
test.beforeEach(async ({ page }) => {
// Mock server with zero repos to show the analyze form
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',
}),
);
});
test('GitHub URL tab validates input', async ({ page }, testInfo) => {
await page.goto('/');
// Wait for analyze form (transition: onboarding → success → analyze)
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
// Type an invalid URL
const input = page.locator('input[type="url"]');
await input.fill('not-a-url');
// Analyze button should be visible but disabled
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
await expect(analyzeBtn).toBeVisible();
// Type a valid GitHub URL
await input.fill('https://github.com/anthropics/courses');
await page.screenshot({ path: testInfo.outputPath('valid-github-url.png') });
});
test('Local Folder tab shows browse button', async ({ page }, testInfo) => {
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
// Switch to Local Folder tab
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Upload-a-folder button should be visible (browser folder upload)
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
});
test('switching tabs clears input', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
// Type in GitHub URL
const urlInput = page.locator('input[type="url"]');
await urlInput.fill('https://github.com/test/repo');
// Switch to Local Folder
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Switch back to GitHub URL — input should be empty
await page.getByRole('tab', { name: 'GitHub URL' }).click();
const newUrlInput = page.locator('input[type="url"]');
await expect(newUrlInput).toHaveValue('');
});
});
// ── Flow 4: Repo dropdown (requires running server) ────────────────────────
test.describe('Flow 4: Repo dropdown in exploring view', () => {
const SKIP_MSG = 'Requires running gitnexus server with indexed repos';
// enterExploringView() can take up to ~45s under parallel CI workers; combined
// with the dropdown interactions this can exceed the default 60s test budget.
test.slow();
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
if (!res.ok) {
test.skip(true, SKIP_MSG);
return;
}
const repos = await res.json();
if (!repos.length) {
test.skip(true, 'Server has no indexed repos');
return;
}
} catch {
test.skip(true, SKIP_MSG);
}
});
test('project badge opens repo dropdown', async ({ page }, testInfo) => {
await enterExploringView(page);
await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
// Click the project badge (has a chevron)
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Repo dropdown should be visible
await expect(page.getByText('Repositories')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('Analyze a new repository')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('repo-dropdown-open.png') });
});
test('analyze option opens inline form', async ({ page }, testInfo) => {
await enterExploringView(page);
// Open repo dropdown
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Click "Analyze a new repository..."
await page.getByText('Analyze a new repository').click();
// Should show the analyze form inline
await expect(page.getByText('GitHub URL')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('Local Folder')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('inline-analyze-form.png') });
});
});