Repository Public
1import { expect, test } from '@playwright/test';2import { apiLogin, expectTerm, openShell, resetMock, SEED, termStatus, termText, totp, waitTermOpen, watch } from './helpers';34test.describe.configure({ mode: 'serial' });5test.beforeAll(async ({ browser }) => {6 const page = await browser.newPage();7 await resetMock(page);8 await page.close();9});1011test('login: password → TOTP → workspace, then create a shell session and run a command', async ({ page }) => {12 const w = watch(page);13 await page.goto('/');14 await expect(page).toHaveURL(/\/login\//);15 await page.getByTestId('login-username').fill(SEED.username);16 await page.getByTestId('login-password').fill(SEED.password);17 await page.getByTestId('login-submit').click();18 await page.getByTestId('login-totp').fill(totp(SEED.totpSecret));19 await page.getByTestId('totp-submit').click();20 await expect(page).toHaveURL(/\/terminal\//);21 await expect(page.getByTestId('start-view')).toBeVisible();22 await expect(page.getByTestId('greeting')).toContainText('admin');23 await expect(page.getByTestId('no-session')).toContainText('No sessions yet');2425 await page.getByTestId('empty-new-session').click();26 await page.getByTestId('kind-shell').click();27 await page.getByTestId('create-session').click();28 await expect(page).toHaveURL(/\/terminal\/\?s=[0-9a-f-]{36}/);29 const id = new URL(page.url()).searchParams.get('s')!;30 await waitTermOpen(page, id);31 await page.getByTestId('terminal').click();32 await page.keyboard.type('echo cr-$((6*7))\n');33 await expectTerm(page, id, /^cr-42$/m);34 await expect(page.getByTestId('conn-indicator')).toHaveAttribute('data-tone', 'success');3536 // reload → same session re-attaches and the earlier output is repainted37 await page.reload();38 await waitTermOpen(page, id);39 await expectTerm(page, id, /^cr-42$/m);40 expect(new URL(page.url()).searchParams.get('s')).toBe(id);41 w.expectClean();42});4344test('login: wrong credentials give a generic error, then a lockout countdown', async ({ page }) => {45 await page.goto('/login/');46 for (let i = 0; i < 5; i++) {47 await page.getByTestId('login-username').fill(i % 2 ? 'nobody' : SEED.username);48 await page.getByTestId('login-password').fill('wrong-password-000');49 await page.getByTestId('login-submit').click();50 if (i < 4) await expect(page.getByTestId('login-error')).toHaveText('Sign-in failed. Check your credentials and try again.');51 }52 await expect(page.getByTestId('lockout')).toContainText(/Try again in \d+ s/);53 await expect(page.getByTestId('login-submit')).toBeDisabled();54 await expect(page.getByTestId('lockout')).toBeHidden({ timeout: 8000 });55 await expect(page.getByTestId('login-submit')).toBeEnabled();56});5758test('network drop: the terminal reconnects by itself and keeps working', async ({ page }) => {59 const w = watch(page);60 await apiLogin(page);61 const id = await openShell(page, 'drop test');62 await page.getByTestId('terminal').click();63 await page.keyboard.type('echo before-drop\n');64 await expectTerm(page, id, /^before-drop$/m);65 await page.evaluate((i) => window.__crTerm.dropSocket(i), id);66 await expect.poll(() => termStatus(page, id)).toBe('reconnecting');67 await expect(page.getByTestId('term-reconnecting')).toBeVisible();68 await waitTermOpen(page, id);69 await expect(page.getByTestId('back-online')).toHaveText('Back online — caught up');70 await expectTerm(page, id, /^before-drop$/m); // repainted after re-attach71 await page.keyboard.type('echo after-$((40+2))\n');72 await expectTerm(page, id, /^after-42$/m);73 // exactly one session exists for this title: reconnecting never creates sessions74 const sessions = (await (await page.request.get('/api/v1/sessions')).json()) as { title: string }[];75 expect(sessions.filter((s) => s.title === 'drop test')).toHaveLength(1);76 w.expectClean();77});7879test('session isolation: input goes only to the visible terminal; hidden tab does not steal focus', async ({ page }) => {80 await apiLogin(page);81 const a = await openShell(page, 'iso A');82 const b = await openShell(page, 'iso B');83 await expect(page.getByTestId('terminal-tab')).toHaveCount(2);84 expect(await page.getByTestId('terminal').getAttribute('data-session-id')).toBe(b);85 await page.getByTestId('terminal').click();86 await page.keyboard.type('echo only-in-B\n');87 await expectTerm(page, b, /^only-in-B$/m);88 expect(await termText(page, a)).not.toContain('only-in-B');89 expect(await page.evaluate((i) => window.__crTerm.hasFocus(i), a)).toBe(false);9091 await page.locator(`[data-testid="terminal-tab"][data-session-id="${a}"]`).click();92 await expect(page.getByTestId('terminal')).toHaveAttribute('data-session-id', a);93 await expect.poll(() => page.evaluate((i) => window.__crTerm.hasFocus(i), a)).toBe(true);94 await page.keyboard.type('echo only-in-A\n');95 await expectTerm(page, a, /^only-in-A$/m);96 expect(await termText(page, b)).not.toContain('only-in-A');97 expect(await termStatus(page, b)).toBe('open'); // hidden tab keeps its stream98});99100test('session exit: 4410 shows “Session ended (exit N)” with Restart; no automatic new session', async ({ page }) => {101 await apiLogin(page);102 const id = await openShell(page, 'exit test');103 await page.getByTestId('terminal').click();104 await page.keyboard.type('exit 7\n');105 await expect(page.getByTestId('term-overlay')).toHaveAttribute('data-kind', 'ended');106 await expect(page.getByText('Session ended (exit 7)')).toBeVisible();107 await page.waitForTimeout(1500);108 expect(await termStatus(page, id)).toBe('ended');109 await page.getByRole('button', { name: 'Restart' }).click();110 await expect(page).not.toHaveURL(new RegExp(id));111 const next = new URL(page.url()).searchParams.get('s')!;112 await waitTermOpen(page, next);113});114115test('theme: light by default, toggle to dark applies everywhere, persists and is set before first paint', async ({ page }) => {116 const w = watch(page);117 await apiLogin(page);118 const id = await openShell(page, 'theme test');119 const html = page.locator('html');120 await expect(html).toHaveAttribute('data-theme', 'light');121 await expect(page.getByTestId('terminal')).toHaveAttribute('data-term-theme', 'light');122 await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#F7F6F3');123 const bgLight = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);124 await page.getByTestId('theme-toggle').click();125 await expect(html).toHaveAttribute('data-theme', 'dark');126 await expect(page.getByTestId('terminal')).toHaveAttribute('data-term-theme', 'dark'); // terminalTheme "auto" follows the app127 await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#080B10');128 await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)).not.toBe(bgLight);129 await expect.poll(async () => (await (await page.request.get('/api/v1/settings')).json()).theme).toBe('dark');130 // reload: /theme-init.js (external, CSP-clean) sets the theme before the app boots131 await page.reload({ waitUntil: 'commit' });132 await expect(html).toHaveAttribute('data-theme', 'dark');133 await waitTermOpen(page, id);134 await page.getByTestId('theme-toggle').click();135 await expect(html).toHaveAttribute('data-theme', 'light');136 w.expectClean();137});138139test('scrollback: scrolling up shows the jump-to-bottom button, with an unread marker when output arrives', async ({ page }) => {140 await apiLogin(page);141 const id = await openShell(page, 'scroll test');142 await page.getByTestId('terminal').click();143 await page.keyboard.type('seq 1 300\n');144 await expectTerm(page, id, /^300$/m);145 await expect(page.getByTestId('scroll-bottom')).toHaveCount(0);146 await page.getByTestId('terminal').hover();147 await page.mouse.wheel(0, -1500);148 const fab = page.getByTestId('scroll-bottom');149 await expect(fab).toBeVisible();150 await expect(fab).not.toHaveAttribute('data-unread', 'true');151 await page.evaluate((i) => window.__crTerm.send(i, 'echo fresh-output\r'), id);152 await expect(fab).toHaveAttribute('data-unread', 'true');153 await fab.click();154 await expect(fab).toHaveCount(0);155 await expectTerm(page, id, /^fresh-output$/m);156});157