SPB Git

Repository Public

mobile.spec.ts 10.1 KB 178 lines typescript

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

Theme