Repository Public
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