Repository Public
1import fs from 'node:fs';2import { expect, test, type Page } from '@playwright/test';3import { totp, watch, termStatus, termText, expectTerm } from '../e2e/helpers';45const DATA = process.env.CR_DEV_DATA ?? '/private/tmp/cr-dev-data';6const CREDS = `${DATA}/e2e-admin.json`;7const USER = 'e2eadmin';8const PASS = 'e2e-real-backend-passphrase-1';9let secret = fs.existsSync(CREDS) ? (JSON.parse(fs.readFileSync(CREDS, 'utf8')).secret as string) : '';10let lastCode = '';1112/** The server burns TOTP counters (replay protection): never present the same code twice. */13async function freshCode(): Promise<string> {14 for (;;) {15 const c = totp(secret);16 if (c !== lastCode) return (lastCode = c);17 await new Promise((r) => setTimeout(r, 1000));18 }19}20async function uiLogin(page: Page): Promise<void> {21 await page.goto('/login/');22 await page.getByTestId('login-username').fill(USER);23 await page.getByTestId('login-password').fill(PASS);24 await page.getByTestId('login-submit').click();25 await page.getByTestId('login-totp').fill(await freshCode());26 await page.getByTestId('totp-submit').click();27 await expect(page).toHaveURL(/\/terminal\//, { timeout: 20_000 });28}29// The CSRF token of the session is read once.30let csrfCache = '';31const api = async (page: Page, method: 'GET' | 'POST', path: string, data?: unknown) => {32 const csrf = method === 'GET' ? '' : (csrfCache ||= (await (await page.request.get('/api/v1/auth/status')).json()).csrfToken as string);33 const r = await page.request.fetch(`/api/v1${path}`, { method, data, headers: { 'X-CSRF-Token': csrf, Origin: new URL(page.url()).origin } });34 expect(r.ok(), `${method} ${path} → ${r.status()} ${await r.text()}`).toBeTruthy();35 return r.json();36};37const waitOpen = (page: Page, id: string) => expect.poll(() => termStatus(page, id), { timeout: 20_000 }).toBe('open');3839test.describe.configure({ mode: 'serial' });4041test('real backend: first-run setup and TOTP enrolment (or reuse of the saved test administrator)', async ({ page }) => {42 const w = watch(page);43 const status = await (await page.request.get('/api/v1/auth/status')).json();44 test.skip(!status.setupRequired && !secret, 'an administrator already exists and no saved test credentials: use a fresh dev database');45 if (!status.setupRequired) return;46 await page.goto('/');47 await expect(page).toHaveURL(/\/setup\//);48 await page.getByTestId('setup-token').fill(fs.readFileSync(`${DATA}/setup-token`, 'utf8').trim());49 await page.getByTestId('setup-username').fill(USER);50 await page.getByTestId('setup-password').fill(PASS);51 await page.getByTestId('setup-confirm').fill(PASS);52 await page.getByTestId('setup-submit').click();53 await page.getByTestId('totp-start').click();54 secret = (await page.getByTestId('totp-secret').getAttribute('data-secret'))!;55 fs.writeFileSync(CREDS, JSON.stringify({ secret }), { mode: 0o600 });56 await page.getByTestId('totp-enroll-code').fill(await freshCode());57 await page.getByRole('button', { name: 'Verify and enable' }).click();58 await expect(page).toHaveURL(/\/terminal\//, { timeout: 20_000 });59 await expect(page.getByTestId('no-session')).toBeVisible();60 w.expectClean();61});6263test('real backend: login, shell session in tmux, echo, reload re-attach, socket drop, file edit + conflict, task log, dashboards', async ({ page }) => {64 const w = watch(page);65 await uiLogin(page);6667 // project (real directory under the dev workspace root)68 const name = `e2e-${Date.now().toString(36)}`;69 await page.goto('/projects/');70 await page.getByTestId('new-project').click();71 await page.getByTestId('project-name').fill(name);72 await page.getByTestId('create-project').click();73 await expect(page.getByTestId('project-card').filter({ hasText: name })).toBeVisible();74 const project = ((await api(page, 'GET', '/projects')) as { id: string; name: string }[]).find((p) => p.name === name)!;7576 // shell session through the UI77 await page.getByTestId('project-card').filter({ hasText: name }).getByRole('button', { name: 'Open terminal' }).click();78 await page.getByTestId('kind-shell').click();79 await page.getByTestId('create-session').click();80 await expect(page).toHaveURL(/\/terminal\/\?s=[0-9a-f-]{36}/, { timeout: 20_000 });81 const id = new URL(page.url()).searchParams.get('s')!;82 await waitOpen(page, id);83 await page.getByTestId('terminal').click();84 await page.keyboard.type('echo cr-$((6*7))\n');85 await expectTerm(page, id, /^cr-42$/m);86 await expect(page.getByTestId('conn-indicator')).toHaveAttribute('data-tone', 'success');87 await expect(page.getByTestId('conn-indicator')).toContainText(/\d+ ms/); // real ping/pong latency8889 // reload → same tmux session, repainted by the new attach90 await page.reload();91 await waitOpen(page, id);92 await expectTerm(page, id, /^cr-42$/m);93 // dropped socket → automatic reconnect, same session94 await page.evaluate((i) => window.__crTerm.dropSocket(i), id);95 await waitOpen(page, id);96 await expectTerm(page, id, /^cr-42$/m);9798 // state: this browser is attached ⇒ RUNNING (not DETACHED) right away99 await expect.poll(async () => ((await api(page, 'GET', `/sessions/${id}`)) as { state: string }).state, { timeout: 3000 }).toBe('RUNNING');100 expect(((await api(page, 'GET', `/sessions/${id}`)) as { attachedClients: number }).attachedClients).toBe(1);101 // page loads are not on the login rate limit: 40 status reads in a row (20/min used to be the cap)102 for (let i = 0; i < 40; i++) expect((await page.request.get('/api/v1/auth/status')).status()).toBe(200);103104 // scrollback: lines that scrolled off are in the terminal's own (normal-buffer) scrollback105 const numbered = async () => (await termText(page, id)).split('\n').map((l) => l.trim()).filter((l) => /^sb-\d+$/.test(l)).map((l) => Number(l.slice(3)));106 const upTo = (n: number) => Array.from({ length: n }, (_, i) => i + 1);107 await page.getByTestId('terminal').click();108 await page.keyboard.type("seq -f 'sb-%.0f' 1 300\n");109 await expect.poll(numbered, { timeout: 15_000 }).toEqual(upTo(300));110 const scroll = () => page.evaluate((i) => window.__crTerm.scroll(i), id);111 expect((await scroll())?.type).toBe('normal');112 expect((await scroll())?.baseY).toBeGreaterThan(250);113 // the mouse wheel scrolls the terminal itself (tmux mouse mode is off, nothing is sent to the shell)114 const box = (await page.getByTestId('terminal').boundingBox())!;115 await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);116 for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -600);117 await expect.poll(async () => { const s = await scroll(); return s ? s.baseY - s.viewportY : 0; }).toBeGreaterThan(5);118 // new output while scrolled up is appended below; the viewport stays where the user put it119 const held = (await scroll())!.viewportY;120 await page.evaluate((i) => window.__crTerm.send(i, "seq -f 'sb-%.0f' 301 360\n"), id);121 await expect.poll(numbered, { timeout: 15_000 }).toEqual(upTo(360));122 expect((await scroll())!.viewportY).toBe(held);123 // a reload (fresh terminal, new attach) gets the history back, once; so does a reconnect124 await page.reload();125 await waitOpen(page, id);126 await expect.poll(numbered, { timeout: 15_000 }).toEqual(upTo(360));127 await page.evaluate((i) => window.__crTerm.dropSocket(i), id);128 await waitOpen(page, id);129 await expect.poll(numbered, { timeout: 15_000 }).toEqual(upTo(360));130131 await page.getByTestId('terminal').click();132 await page.keyboard.type("printf 'first line\\n' > note.txt && echo wrote-$((1+1))\n");133 await expectTerm(page, id, /^wrote-2$/m);134 expect(((await api(page, 'GET', '/sessions')) as { id: string }[]).filter((s) => s.id === id)).toHaveLength(1);135136 // files: open, edit, save137 await page.goto(`/files/?p=${project.id}&path=note.txt`);138 const editor = page.locator('.monaco-editor').first();139 await expect(editor).toContainText('first line', { timeout: 30_000 });140 await editor.click();141 await page.keyboard.press('ControlOrMeta+End');142 await page.keyboard.type('edited in browser');143 await page.keyboard.press('ControlOrMeta+s');144 await expect(page.getByTestId('save-state')).toHaveText('Saved');145 expect((await api(page, 'GET', `/projects/${project.id}/file?path=note.txt`)).content).toContain('edited in browser');146 // conflict: the file changes on disk through a real task, then the editor saves147 const bump = await api(page, 'POST', '/tasks', { projectId: project.id, title: 'bump', command: 'echo changed-on-disk >> note.txt' });148 await expect.poll(async () => (await api(page, 'GET', `/tasks/${bump.id}`)).state, { timeout: 20_000 }).toBe('SUCCEEDED');149 await editor.click();150 await page.keyboard.type(' again');151 await page.getByTestId('save-file').click();152 await expect(page.getByRole('dialog', { name: 'File changed on disk' })).toBeVisible();153 await page.getByTestId('conflict-reload').click();154 await expect(editor).toContainText('changed-on-disk');155 await expect(page.getByTestId('save-state')).toHaveText('Saved');156157 // task with a live log158 await page.goto('/tasks/');159 await page.getByTestId('new-task').click();160 await page.getByTestId('task-title').fill('real task');161 await page.getByTestId('task-command').fill('for i in 1 2 3; do echo "line-$i"; sleep 0.5; done; echo artifact > out.tgz');162 await page.getByTestId('task-globs').fill('*.tgz');163 await page.getByTestId('create-task').click();164 await expect(page).toHaveURL(/\/tasks\/\?t=/);165 const log = () => page.evaluate(() => (window as unknown as { __crTaskLog?: () => string }).__crTaskLog?.() ?? '');166 await expect.poll(log, { timeout: 20_000 }).toContain('line-1');167 await expect(page.getByTestId('task-detail').locator('[data-state="SUCCEEDED"]')).toBeVisible({ timeout: 30_000 });168 await expect.poll(log).toContain('line-3');169 await expect(page.getByTestId('artifact')).toContainText('out.tgz');170171 // dashboards render real data without errors172 await page.goto('/system/');173 await expect(page.getByTestId('components')).toBeVisible();174 await expect(page.getByTestId('host-metrics')).toBeVisible();175 for (const route of ['/cluster/', '/skills/', '/mcp/', '/sessions/', '/settings/']) {176 await page.goto(route);177 await page.waitForLoadState('networkidle');178 await expect(page.locator('main')).toBeVisible();179 }180 await page.goto('/cluster/');181 await expect(page.getByTestId('cluster-none').or(page.getByTestId('cluster-totals')).first()).toBeVisible();182 await expect(page.getByTestId('node-row').first()).toBeVisible(); // at least the host itself, with real metrics183184 // exit the shell: the UI reports the end and does not start anything by itself185 await page.goto(`/terminal/?s=${id}`);186 await waitOpen(page, id);187 await page.getByTestId('terminal').click();188 await page.keyboard.type('exit 3\n');189 await expect(page.getByTestId('term-overlay')).toHaveAttribute('data-kind', 'ended', { timeout: 20_000 });190 await expect(page.getByText('Session ended (exit 3)')).toBeVisible();191 expect(await termText(page, id)).toContain('cr-42');192 w.expectClean();193});194