Repository Public
1import { expect, test } from '@playwright/test';2import { apiLogin, apiPost, demoProjectId, expectTerm, openShell, resetMock, SEED, termText, totp, watch } from './helpers';34test.beforeAll(async ({ browser }) => {5 const page = await browser.newPage();6 await resetMock(page);7 await page.close();8});910const key = (page: import('@playwright/test').Page, id: string) => page.locator(`[data-testid="mobile-toolbar"] [data-key="${id}"]`);11const sent = (page: import('@playwright/test').Page, id: string) => page.evaluate((i) => window.__crTerm.sent(i) ?? [], id);1213test('mobile: login, purpose-built layout, toolbar above the terminal, no horizontal overflow', async ({ page }) => {14 const w = watch(page);15 await page.goto('/');16 await page.getByTestId('login-username').fill(SEED.username);17 await page.getByTestId('login-password').fill(SEED.password);18 await page.getByTestId('login-submit').tap();19 await page.getByTestId('login-totp').fill(totp(SEED.totpSecret));20 await page.getByTestId('totp-submit').tap();21 await expect(page.getByTestId('mobile-shell')).toBeVisible();22 await expect(page.getByTestId('mobile-nav')).toBeVisible();23 // phones land on Home: greeting with the real username, honest empty state24 await expect(page).toHaveURL(/\/home\//);25 await expect(page.getByTestId('greeting')).toContainText(/Good (morning|afternoon|evening|night),\s*admin/);26 await expect(page.getByTestId('home-summary')).toHaveText('Nothing is running right now.');27 await expect(page.getByTestId('jump-back-in')).toContainText('No sessions yet');28 await expect(page.getByTestId('host-strip')).toContainText(/as of \d\d:\d\d:\d\d/);29 const id = await openShell(page, 'phone shell');30 await expect(page.getByTestId('mobile-topbar')).toContainText('demo');31 await expect(page.getByTestId('mobile-toolbar')).toBeVisible();32 // real inputs are ≥ 16 px (no iOS focus zoom), xterm's hidden textarea included33 expect(await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.xterm-helper-textarea')!).fontSize))).toBeGreaterThanOrEqual(16);34 // nothing overflows the viewport horizontally; toolbar buttons are 44 px touch targets35 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);36 const box = await key(page, 'esc').boundingBox();37 expect(box!.height).toBeGreaterThanOrEqual(44);38 expect(box!.width).toBeGreaterThanOrEqual(32); // keys share the row: full-height targets, no horizontal scrolling39 await expect(page.getByTestId('mobile-toolbar')).toHaveAttribute('data-rows', '2');40 // terminal fits the phone width41 const size = await page.evaluate((i) => window.__crTerm.size(i), id);42 expect(size!.cols).toBeGreaterThan(30);43 expect(size!.cols).toBeLessThan(70);44 await page.getByTestId('terminal').tap();45 await page.keyboard.type('echo phone-$((6*7))\n');46 await expectTerm(page, id, /^phone-42$/m);47 w.expectClean();48});4950test('mobile toolbar: keys send real sequences; sticky modifiers (one-shot, lock); focus is kept', async ({ page }) => {51 await apiLogin(page);52 const id = await openShell(page, 'toolbar');53 await page.getByTestId('terminal').tap();54 await expect.poll(() => page.evaluate((i) => window.__crTerm.hasFocus(i), id)).toBe(true);5556 // CTRL (one-shot) then "c" typed on the keyboard interrupts a running command57 await page.keyboard.type('sleep 100\n');58 await page.waitForTimeout(300);59 await key(page, 'ctrl').dispatchEvent('pointerdown');60 await expect(key(page, 'ctrl')).toHaveAttribute('data-state', 'once');61 await page.keyboard.type('c');62 await expect(key(page, 'ctrl')).toHaveAttribute('data-state', 'off');63 await expectTerm(page, id, /\^C/);64 expect((await sent(page, id)).at(-1)).toBe('\x03');65 await page.keyboard.type('echo alive-$((1+1))\n');66 await expectTerm(page, id, /^alive-2$/m);6768 // arrow up recalls history, ENTER key runs it again69 await key(page, 'up').dispatchEvent('pointerdown');70 await key(page, 'up').dispatchEvent('pointerup');71 expect((await sent(page, id)).at(-1)).toBe('\x1b[A');72 await key(page, 'enter').dispatchEvent('pointerdown');73 await key(page, 'enter').dispatchEvent('pointerup');74 await expect.poll(async () => ((await termText(page, id)).match(/^alive-2$/gm) ?? []).length).toBe(2);7576 // literal sequences77 for (const [k, seq] of [['esc', '\x1b'], ['tab', '\t'], ['left', '\x1b[D'], ['right', '\x1b[C'], ['down', '\x1b[B'], ['|', '|'], ['~', '~']] as const) {78 await key(page, k).dispatchEvent('pointerdown');79 await key(page, k).dispatchEvent('pointerup');80 expect((await sent(page, id)).at(-1), k).toBe(seq);81 }82 await page.keyboard.press('Control+u'); // hardware keyboards still work; clears the line83 // SHIFT+TAB → CSI Z ; ALT prefixes ESC ; CTRL+arrow uses the xterm modifier form84 await key(page, 'shift').dispatchEvent('pointerdown');85 await key(page, 'tab').dispatchEvent('pointerdown');86 await key(page, 'tab').dispatchEvent('pointerup');87 expect((await sent(page, id)).at(-1)).toBe('\x1b[Z');88 await key(page, 'alt').dispatchEvent('pointerdown');89 await page.keyboard.type('b');90 expect((await sent(page, id)).at(-1)).toBe('\x1bb');91 await key(page, 'ctrl').dispatchEvent('pointerdown');92 await key(page, 'left').dispatchEvent('pointerdown');93 await key(page, 'left').dispatchEvent('pointerup');94 expect((await sent(page, id)).at(-1)).toBe('\x1b[1;5D');9596 // double tap locks; a locked modifier survives several keys; tap again releases97 await key(page, 'ctrl').dispatchEvent('pointerdown');98 await key(page, 'ctrl').dispatchEvent('pointerdown');99 await expect(key(page, 'ctrl')).toHaveAttribute('data-state', 'locked');100 await page.keyboard.type('ae');101 expect((await sent(page, id)).slice(-2)).toEqual(['\x01', '\x05']);102 await expect(key(page, 'ctrl')).toHaveAttribute('data-state', 'locked');103 await key(page, 'ctrl').dispatchEvent('pointerdown');104 await expect(key(page, 'ctrl')).toHaveAttribute('data-state', 'off');105106 // application cursor mode (DECCKM) switches arrows to SS3107 await page.keyboard.press('Control+u');108 await page.keyboard.type("printf '\\033[?1h'\n");109 await page.waitForTimeout(300);110 await key(page, 'up').dispatchEvent('pointerdown');111 await key(page, 'up').dispatchEvent('pointerup');112 expect((await sent(page, id)).at(-1)).toBe('\x1bOA');113 await page.keyboard.press('Control+c');114 await page.keyboard.type("printf '\\033[?1l'\n");115116 // toolbar presses never moved focus away from the terminal117 expect(await page.evaluate((i) => window.__crTerm.hasFocus(i), id)).toBe(true);118 expect(await key(page, 'esc').getAttribute('tabindex')).toBe('-1');119});120121test('mobile: session sheet, switching, font size, files drill-down with full-screen editor', async ({ page }) => {122 const w = watch(page);123 await apiLogin(page);124 const a = await apiPost<{ id: string }>(page, '/sessions', { kind: 'shell', title: 'phone A', projectId: await demoProjectId(page) });125 const b = await openShell(page, 'phone B');126 await page.getByTestId('mobile-switcher').tap();127 await expect(page.getByTestId('sessions-sheet')).toBeVisible();128 await page.waitForTimeout(350); // slide-in finished129 await page.locator(`[data-testid="sessions-sheet"] [data-session-id="${a.id}"]`).tap();130 await expect(page.getByTestId('terminal')).toHaveAttribute('data-session-id', a.id);131 await expect(page).toHaveURL(new RegExp(a.id));132133 const before = (await page.evaluate((i) => window.__crTerm.size(i), a.id))!;134 await page.getByTestId('mobile-overflow').tap();135 await page.getByTestId('font-plus').tap();136 await page.getByTestId('font-plus').tap();137 await page.keyboard.press('Escape');138 await expect.poll(async () => (await page.evaluate((i) => window.__crTerm.size(i), a.id))!.fontSize).toBe(before.fontSize + 2);139 expect((await page.evaluate((i) => window.__crTerm.size(i), a.id))!.cols).toBeLessThan(before.cols); // refit140 expect((await (await page.request.get('/api/v1/settings')).json()).mobileFontSize).toBe(before.fontSize + 2); // persisted141 expect(b).not.toBe(a.id);142143 await page.goto('/home/');144 await expect(page.locator(`[data-testid="session-card"][data-session-id="${a.id}"]`)).toBeVisible();145 await expect(page.getByTestId('home-summary')).toContainText(/\d sessions running/);146 await page.getByTestId('nav-more').tap();147 await page.getByTestId('more-sheet').getByRole('link', { name: 'Files' }).tap();148 await page.locator('[data-testid="file-row"][data-path="docs"]').tap();149 await page.locator('[data-testid="file-row"][data-path="docs/notes.txt"]').tap();150 await expect(page.locator('.monaco-editor').first()).toContainText('needle-in-haystack', { timeout: 20_000 });151 await page.getByRole('button', { name: 'Back to files' }).tap();152 await expect(page.getByTestId('files-browser')).toBeVisible();153154 for (const [route, shot] of [['/cluster/', 'mobile-cluster'], ['/sessions/', 'mobile-sessions'], ['/system/', 'mobile-system'], ['/tasks/', 'mobile-tasks']] as const) {155 await page.goto(route);156 await page.waitForLoadState('networkidle');157 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), route).toBe(true);158 }159 w.expectClean();160});161162test('mobile: larger phone (430×932) and landscape refit', async ({ page }) => {163 await apiLogin(page);164 await page.setViewportSize({ width: 430, height: 932 });165 const id = await openShell(page, 'big phone');166 const portrait = (await page.evaluate((i) => window.__crTerm.size(i), id))!;167 await page.setViewportSize({ width: 932, height: 430 }); // rotation → desktop-width shell; terminal refits168 await expect.poll(async () => (await page.evaluate((i) => window.__crTerm.size(i), id))!.cols).toBeGreaterThan(portrait.cols);169 await page.setViewportSize({ width: 430, height: 932 });170 await expect.poll(async () => (await page.evaluate((i) => window.__crTerm.size(i), id))!.cols).toBe(portrait.cols);171 // keyboard open is simulated by shrinking the visual viewport: bottom nav hides, toolbar stays at the bottom edge172 await page.setViewportSize({ width: 430, height: 560 });173 await expect(page.getByTestId('mobile-shell')).toHaveAttribute('data-keyboard', 'open');174 await expect(page.getByTestId('mobile-nav')).toHaveCount(0);175 const tb = (await page.getByTestId('mobile-toolbar').boundingBox())!;176 expect(Math.round(tb.y + tb.height)).toBe(560);177});178