SPB Git

Repository Public

real-backend.spec.ts 10.8 KB 194 lines typescript

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
Files main
Menu

Theme