SPB Git

Repository Public

features.spec.ts 16.8 KB 281 lines typescript

1import { expect, test } from '@playwright/test';2import { apiLogin, apiPost, demoProjectId, MOCK, openShell, resetMock, watch } from './helpers';34test.beforeAll(async ({ browser }) => {5  const page = await browser.newPage();6  await resetMock(page);7  await page.close();8});9test.beforeEach(async ({ page }) => {10  await apiLogin(page);11});1213test('files: open, edit, save, then a disk change produces the conflict dialog (reload / diff / overwrite)', async ({ page }) => {14  const w = watch(page);15  const workers: string[] = [];16  page.on('worker', (wk) => workers.push(wk.url()));17  const pid = await demoProjectId(page);18  await page.goto(`/files/?p=${pid}`);19  await page.locator('[data-testid="file-row"][data-path="src"]').click();20  await page.locator('[data-testid="file-row"][data-path="src/index.ts"]').click();21  const editor = page.locator('.monaco-editor').first();22  await expect(editor).toBeVisible({ timeout: 20_000 });23  await expect(editor).toContainText('greet');24  // Monaco workers must have been created from same-origin scripts (CSP: worker-src 'self' blob:)25  await expect.poll(() => workers.length).toBeGreaterThan(0);26  expect(workers.every((u) => u.startsWith(`${new URL(page.url()).origin}/_next/static/`))).toBe(true);2728  await editor.click();29  await page.keyboard.press('ControlOrMeta+End');30  await page.keyboard.type('// edited-by-e2e');31  await expect(page.getByTestId('dirty-dot')).toBeVisible();32  await expect(page.getByTestId('save-state')).toHaveText('Unsaved changes');33  await page.keyboard.press('ControlOrMeta+s');34  await expect(page.getByTestId('save-state')).toHaveText('Saved');35  const saved = await (await page.request.get(`/api/v1/projects/${pid}/file?path=src/index.ts`)).json();36  expect(saved.content).toContain('// edited-by-e2e');3738  // the file changes on disk behind the editor's back39  await page.request.post(`${MOCK}/__mock/touch-file`, { data: { projectId: pid, path: 'src/index.ts', append: '\n// changed-on-disk\n' } });40  await editor.click();41  await page.keyboard.type(' mine');42  await page.getByTestId('save-file').click();43  await expect(page.getByRole('dialog', { name: 'File changed on disk' })).toBeVisible();44  await expect(page.getByTestId('save-state')).toHaveText('Conflict');4546  await page.getByTestId('conflict-diff').click();47  await expect(page.locator('.monaco-diff-editor')).toBeVisible({ timeout: 20_000 });48  await expect(page.locator('.monaco-diff-editor')).toContainText('changed-on-disk');49  await page.getByTestId('editor-tab').first().click();50  await page.getByTestId('save-file').click();51  await page.getByTestId('conflict-overwrite').click();52  await expect(page.getByTestId('save-state')).toHaveText('Saved');53  const after = await (await page.request.get(`/api/v1/projects/${pid}/file?path=src/index.ts`)).json();54  expect(after.content).toContain('mine');55  expect(after.content).not.toContain('changed-on-disk');5657  // binary and oversized files are not opened as text58  await page.locator('[data-testid="file-row"][data-path="blob.bin"]').click();59  await expect(page.getByTestId('file-meta')).toContainText('Binary file');60  await page.locator('[data-testid="file-row"][data-path="big.log"]').click();61  await expect(page.getByTestId('file-meta')).toContainText('larger than 2 MiB');62  await page.locator('[data-testid="file-row"][data-path="pixel.png"]').click();63  await expect(page.getByTestId('image-preview').locator('img')).toHaveJSProperty('naturalWidth', 1); // rendered from a blob: URL64  // JSON language worker starts cleanly (any worker failure is caught by watch()) and the pretty view works65  await page.locator('[data-testid="file-row"][data-path="data.json"]').click();66  await expect(page.locator('.monaco-editor').first()).toContainText('"nested"');67  await page.getByRole('button', { name: 'Pretty' }).click();68  await expect(page.getByTestId('json-pretty')).toContainText('"ok": true');69  // markdown preview + git diff70  await page.locator('[data-testid="file-row"][data-path="README.md"]').click();71  await page.getByRole('button', { name: 'Preview' }).click();72  await expect(page.getByTestId('markdown-preview').getByRole('heading', { name: 'Demo project' })).toBeVisible();73  await page.getByRole('tab', { name: 'Git' }).click();74  await page.getByTestId('git-file').filter({ hasText: 'src/util.ts' }).click();75  await expect(page.locator('.monaco-diff-editor')).toContainText('sub');76  await expect(page.locator('.monaco-diff-editor .line-insert, .monaco-diff-editor .char-insert').first()).toBeVisible(); // diff computed by the worker77  w.expectClean();78});7980test('files: create, rename, delete, content search', async ({ page }) => {81  const pid = await demoProjectId(page);82  await page.goto(`/files/?p=${pid}`);83  await page.getByRole('button', { name: 'New file' }).click();84  await page.getByTestId('file-op-input').fill('e2e-new.txt');85  await page.getByTestId('file-op-submit').click();86  const row = page.locator('[data-testid="file-row"][data-path="e2e-new.txt"]');87  await expect(row).toBeVisible();88  await page.getByRole('button', { name: 'Actions for e2e-new.txt' }).click();89  await page.getByRole('menuitem', { name: 'Rename' }).click();90  await page.getByTestId('file-op-input').fill('e2e-renamed.txt');91  await page.getByTestId('file-op-submit').click();92  await expect(page.locator('[data-testid="file-row"][data-path="e2e-renamed.txt"]')).toBeVisible();93  await page.getByRole('button', { name: 'Actions for e2e-renamed.txt' }).click();94  await page.getByRole('menuitem', { name: 'Delete…' }).click();95  await page.getByRole('button', { name: 'Delete', exact: true }).click();96  await expect(page.locator('[data-testid="file-row"][data-path="e2e-renamed.txt"]')).toHaveCount(0);9798  // upload through the picker; an existing destination is never replaced silently99  const file = { name: 'upload.txt', mimeType: 'text/plain', buffer: Buffer.from('uploaded-by-e2e') };100  await page.locator('input[type="file"]').setInputFiles(file);101  await expect(page.getByTestId('uploads')).toContainText('Uploaded');102  await expect(page.locator('[data-testid="file-row"][data-path="upload.txt"]')).toBeVisible();103  await page.locator('input[type="file"]').setInputFiles({ ...file, buffer: Buffer.from('second-version') });104  await expect(page.getByTestId('uploads')).toContainText('Already exists');105  await page.getByRole('button', { name: 'Replace' }).click();106  await expect.poll(async () => (await (await page.request.get(`/api/v1/projects/${pid}/file?path=upload.txt`)).json()).content).toBe('second-version');107108  await page.getByRole('button', { name: 'Name', exact: true }).click();109  await page.getByTestId('file-search').fill('needle-in-haystack');110  await expect(page.getByTestId('search-results')).toContainText('docs/notes.txt:2');111});112113test('tasks: create a task, watch the live log, see the final state and the artifact', async ({ page }) => {114  const w = watch(page);115  await page.goto('/tasks/');116  await expect(page.getByText('No tasks yet')).toBeVisible();117  await page.getByTestId('new-task').click();118  await page.getByTestId('task-title').fill('e2e build');119  await page.getByTestId('task-command').fill('for i in 1 2 3; do echo "line-$i"; sleep 0.4; done; printf "\\033[32mgreen-done\\033[0m\\n"; echo artifact > e2e-out.tgz');120  await page.getByTestId('task-globs').fill('*.tgz');121  await page.getByTestId('task-idempotent').check();122  await page.getByTestId('create-task').click();123  await expect(page).toHaveURL(/\/tasks\/\?t=/);124  const detail = page.getByTestId('task-detail');125  await expect(detail.locator('[data-state="RUNNING"]')).toBeVisible();126  const log = () => page.evaluate(() => (window as unknown as { __crTaskLog?: () => string }).__crTaskLog?.() ?? '');127  await expect.poll(log).toContain('line-1');128  await expect(detail.locator('[data-state="SUCCEEDED"]')).toBeVisible({ timeout: 15_000 });129  await expect.poll(log).toMatch(/line-3[\s\S]*green-done/);130  expect(await log()).not.toContain('[32m'); // ANSI is interpreted, not printed131  await expect(page.getByTestId('artifact')).toContainText('e2e-out.tgz');132  await expect(detail).toContainText('not measured');133  await expect(page.getByTestId('retry-task')).toBeVisible();134135  // cancel a long task; a non-idempotent task offers no retry136  const t = await apiPost<{ id: string }>(page, '/tasks', { title: 'long', command: 'sleep 60' });137  await page.goto(`/tasks/?t=${t.id}`);138  await page.getByTestId('cancel-task').click();139  await page.getByRole('button', { name: 'Cancel task' }).click();140  await expect(page.getByTestId('task-detail').locator('[data-state="CANCELLED"]')).toBeVisible();141  await expect(page.getByTestId('retry-task')).toHaveCount(0);142  w.expectClean();143});144145test('cluster: fixture renders; stale nodes are UNKNOWN; topology and node drawer', async ({ page }) => {146  const w = watch(page);147  await page.goto('/cluster/');148  await expect(page.getByTestId('node-row')).toHaveCount(7);149  // FIXTURE-mini-1 is reported ONLINE by the API but stale → must be shown UNKNOWN150  const stale = page.locator('[data-testid="node-row"][data-node="FIXTURE-mini-1"]');151  await expect(stale).toHaveAttribute('data-health', 'UNKNOWN');152  await expect(stale).toContainText('UNKNOWN');153  await expect(stale.locator('[data-stale]').first()).toBeVisible();154  await expect(page.locator('[data-testid="node-row"][data-node="FIXTURE-studio-1"]')).toHaveAttribute('data-health', 'ONLINE');155  await expect(page.locator('[data-testid="node-row"][data-node="FIXTURE-mini-2"]')).toContainText('—');156  await expect(page.getByTestId('cluster-collected')).toContainText(/Collected \d{4}-\d{2}-\d{2}/);157  await expect(page.getByText('Local model inference is separate from the Claude Code runtime')).toBeVisible();158159  await page.getByTestId('view-topology').click();160  await expect(page.getByTestId('topology-node')).toHaveCount(7);161  await expect(page.locator('[data-testid="topology-node"][data-health="UNKNOWN"]')).toHaveCount(3);162  await expect(page.locator('.react-flow__edge')).toHaveCount(8);163  await page.waitForTimeout(400);164  await page.getByTestId('topology-node').filter({ hasText: 'FIXTURE-studio-2' }).click();165  await expect(page.getByTestId('node-detail')).toContainText('DEGRADED');166  w.expectClean();167});168169test('command palette and quick switcher navigate by keyboard', async ({ page }) => {170  const w = watch(page);171  const a = await openShell(page, 'palette A');172  const b = await openShell(page, 'palette B');173  await page.keyboard.press('ControlOrMeta+k');174  await page.getByTestId('palette-input').fill('cluster');175  await page.keyboard.press('Enter');176  await expect(page).toHaveURL(/\/cluster\//);177  await page.keyboard.press('ControlOrMeta+k');178  await page.getByTestId('palette-input').fill('palette A');179  await page.keyboard.press('Enter');180  await expect(page).toHaveURL(new RegExp(`/terminal/\\?s=${a}`));181  await expect(page.getByTestId('terminal')).toHaveAttribute('data-session-id', a);182  // quick switcher preselects the previous session183  await page.keyboard.press('ControlOrMeta+j');184  await expect(page.getByTestId('switcher-input')).toBeVisible();185  await page.keyboard.press('Enter');186  await expect(page.getByTestId('terminal')).toHaveAttribute('data-session-id', b);187  // file search through the palette188  await page.keyboard.press('ControlOrMeta+k');189  await page.getByTestId('palette-input').fill('notes');190  await page.getByRole('option', { name: /docs\/notes\.txt/ }).click();191  await expect(page).toHaveURL(/\/files\/\?p=.*notes\.txt/);192  await expect(page.locator('.monaco-editor').first()).toContainText('needle-in-haystack', { timeout: 20_000 });193  w.expectClean();194});195196test('sessions page: states, waiting highlight, recoverable restart explanation, rename, terminate', async ({ page }) => {197  const a = await apiPost<{ id: string }>(page, '/sessions', { kind: 'claude', title: 'waiting one', projectId: await demoProjectId(page) });198  const b = await apiPost<{ id: string }>(page, '/sessions', { kind: 'shell', title: 'recoverable one' });199  await page.goto('/sessions/');200  await expect(page.getByTestId('session-item').filter({ hasText: 'waiting one' })).toBeVisible();201  await page.request.post(`${MOCK}/__mock/session-state`, { data: { id: a.id, state: 'WAITING_FOR_INPUT', waitingReason: 'Claude needs your permission' } });202  await page.request.post(`${MOCK}/__mock/session-state`, { data: { id: b.id, state: 'RECOVERABLE' } });203  // pushed live through the events WebSocket — no reload204  const waiting = page.locator('[data-testid="session-item"][data-state="WAITING_FOR_INPUT"]');205  await expect(waiting).toContainText('Claude needs your permission');206  const rec = page.locator('[data-testid="session-item"][data-state="RECOVERABLE"]');207  await expect(rec).toContainText('The host restarted; the process is gone. Restart resumes the Claude conversation, not the process.');208  await expect(page.getByText('Session waiting for input').first()).toBeVisible(); // toast209210  await rec.getByRole('button', { name: 'Restart' }).click();211  await page.getByRole('dialog').getByRole('button', { name: 'Restart' }).click();212  await expect(page).toHaveURL(new RegExp(`/terminal/\\?s=${b.id}`));213  await page.goto('/sessions/');214  const item = page.getByTestId('session-item').filter({ hasText: 'waiting one' });215  await item.getByRole('button', { name: /Actions for/ }).click();216  await page.getByRole('menuitem', { name: 'Rename' }).click();217  await page.getByLabel('Session title').fill('renamed one');218  await page.keyboard.press('Enter');219  await expect(page.getByTestId('session-item').filter({ hasText: 'renamed one' })).toBeVisible();220  await page.getByTestId('session-item').filter({ hasText: 'renamed one' }).getByRole('button', { name: /Actions for/ }).click();221  await page.getByRole('menuitem', { name: 'Terminate…' }).click();222  await page.getByRole('button', { name: 'Terminate', exact: true }).click();223  await expect(page.locator('[data-testid="session-item"][data-state="STOPPED"]').filter({ hasText: 'renamed one' })).toBeVisible();224});225226test('every route renders without console errors or CSP violations; system, skills, MCP, settings', async ({ page }) => {227  const w = watch(page);228  for (const route of ['/projects/', '/sessions/', '/files/', '/tasks/', '/cluster/', '/skills/', '/mcp/', '/system/', '/settings/', '/terminal/']) {229    await page.goto(route);230    await expect(page.locator('main')).toBeVisible();231    await page.waitForLoadState('networkidle');232  }233  await page.goto('/system/');234  await expect(page.getByTestId('components')).toContainText('server (dev mock)');235  await expect(page.getByTestId('system-collected')).toContainText(/Collected \d{4}/);236  await expect(page.getByTestId('claude-info')).toContainText('UNKNOWN');237  await expect(page.getByTestId('host-metrics')).toContainText('not measured'); // cpuPct is null in the mock238  await expect(page.getByTestId('audit-table')).toBeVisible();239240  await page.goto('/skills/');241  await expect(page.getByTestId('registry-disconnected')).toBeVisible();242  await expect(page.getByTestId('skill-row')).toHaveCount(3);243244  await page.goto('/mcp/');245  await page.getByTestId('add-mcp').click();246  await page.getByTestId('mcp-name').fill('e2e-server');247  await page.getByTestId('mcp-target').fill('npx');248  await page.getByRole('button', { name: 'Add', exact: true }).click();249  await page.getByLabel('Environment variables name 1').fill('E2E_TOKEN');250  await page.getByLabel('Environment variables value 1').fill('super-secret-value');251  await expect(page.getByLabel('Environment variables value 1')).toHaveAttribute('type', 'password');252  await page.getByTestId('mcp-submit').click();253  const row = page.getByTestId('mcp-row').filter({ hasText: 'e2e-server' });254  await expect(row).toContainText('E2E_TOKEN=••••');255  expect(await page.content()).not.toContain('super-secret-value');256257  await page.goto('/projects/');258  await expect(page.getByTestId('project-card')).toContainText('demo');259  await page.goto('/settings/');260  await expect(page.getByTestId('devices')).toContainText('this browser');261  w.expectClean();262});263264test('production CSP header is present and the PWA assets are served', async ({ page, request }) => {265  const res = await request.get('/terminal/');266  const csp = res.headers()['content-security-policy'] ?? '';267  expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval' 'sha256-");268  expect(csp).not.toContain("'unsafe-eval'");269  expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);270  expect(csp).not.toMatch(/https?:\/\//); // no third-party origin anywhere271  const manifest = await (await request.get('/manifest.webmanifest')).json();272  expect(manifest.display).toBe('standalone');273  for (const icon of manifest.icons) expect((await request.get(icon.src)).ok()).toBeTruthy();274  expect((await request.get('/icons/apple-touch-icon.png')).ok()).toBeTruthy();275  const sw = await (await request.get('/sw.js')).text();276  expect(sw).toContain("url.pathname.startsWith('/api/')");277  await page.goto('/login/');278  await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');279  await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', /viewport-fit=cover.*interactive-widget=resizes-content/);280});281
Files main
Menu

Theme