diff --git a/browse/test/fixtures/touch.html b/browse/test/fixtures/touch.html new file mode 100644 index 000000000..9e5855bc2 --- /dev/null +++ b/browse/test/fixtures/touch.html @@ -0,0 +1,64 @@ + + + + +Touch fixture + + + + +
zoom 1
+
joystick
+ + + + diff --git a/browse/test/touch-commands.test.ts b/browse/test/touch-commands.test.ts new file mode 100644 index 000000000..a8caa41d8 --- /dev/null +++ b/browse/test/touch-commands.test.ts @@ -0,0 +1,229 @@ +/** + * Phone emulation and touch input on gstack's own Chromium (#3058, GSTA-227 F3). + * + * Drives a real in-process BrowserManager (same harness as snapshot.test.ts) + * against browse/test/fixtures/touch.html: a touch logger, a swipe carousel, + * a two-finger zoom surface and a joystick plus fire button. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { startTestServer } from './test-server'; +import { BrowserManager } from '../src/browser-manager'; +import { handleWriteCommand } from '../src/write-commands'; +import { handleMetaCommand } from '../src/meta-commands'; +import { WRITE_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands'; +import { SCOPE_WRITE } from '../src/token-registry'; +import { TUNNEL_COMMANDS } from '../src/routes/commands'; + +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +let testServer: ReturnType; +let bm: BrowserManager; +let baseUrl: string; +const shutdown = async () => {}; +const VERBS = ['device', 'tap', 'swipe', 'pinch', 'touch']; + +beforeAll(async () => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; + testServer = startTestServer(0); + baseUrl = testServer.url; + bm = new BrowserManager(); + await bm.launch(); +}); + +afterAll(async () => { + try { testServer.server.stop(true); } catch {} + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + +const run = (cmd: string, args: string[] = []) => handleWriteCommand(cmd, args, bm.getActiveSession(), bm); +const page = () => bm.getActiveSession().getPage(); +const probe = () => page().evaluate(() => ({ + coarse: matchMedia('(pointer: coarse)').matches, + maxTouchPoints: navigator.maxTouchPoints, + ua: navigator.userAgent, + width: innerWidth, +})); +const data = (sel: string, key: string) => page().evaluate(([s, k]) => (document.querySelector(s) as HTMLElement).dataset[k], [sel, key]); + +async function freshTouchTab(device: string | null = 'iPhone 15'): Promise { + const id = await bm.newTab(baseUrl + '/touch.html'); + bm.switchTab(id, { bringToFront: false }); + if (device) await run('device', [device]); + await page().reload({ waitUntil: 'domcontentloaded' }); + return id; +} + +describe('device and touch: registration', () => { + test('verbs are write commands with descriptions, in the write scope, never on the tunnel', () => { + for (const verb of VERBS) { + expect(WRITE_COMMANDS.has(verb)).toBe(true); + expect(SCOPE_WRITE.has(verb)).toBe(true); + expect(TUNNEL_COMMANDS.has(verb)).toBe(false); + expect(COMMAND_DESCRIPTIONS[verb]?.usage).toContain(verb); + } + }); +}); + +describe('device', () => { + test('unknown name suggests the nearest names and leaves the tab alone', async () => { + await freshTouchTab(null); + const before = await probe(); + let message = ''; + try { await run('device', ['iPhone 99']); } catch (err: any) { message = err.message; } + expect(message).toContain('BROWSE_DEVICE_UNKNOWN'); + expect(message).toContain('iPhone 99'); + expect(message).toContain('fix: $B device --list'); + expect(message.match(/iPhone \d/g)?.length ?? 0).toBeGreaterThanOrEqual(3); + expect(await probe()).toEqual(before); + expect(await run('device')).toContain('none'); + }); + + test('--list prints Playwright device names', async () => { + const list = await run('device', ['--list']); + expect(list).toContain('iPhone 15'); + expect(list).toContain('Pixel 7'); + }); + + test('emulates the phone on this tab only; device off restores', async () => { + const desktopId = await freshTouchTab(null); + const desktop = await probe(); + await freshTouchTab('iphone 15'); + const phone = await probe(); + expect(phone.coarse).toBe(true); + expect(phone.maxTouchPoints).toBe(5); + expect(phone.ua).toContain('iPhone'); + expect(phone.width).toBe(393); + expect(await run('device')).toContain('iPhone 15'); + + const other = bm.getSession(desktopId).getPage(); + const otherNow = await other.evaluate(() => ({ coarse: matchMedia('(pointer: coarse)').matches, maxTouchPoints: navigator.maxTouchPoints, ua: navigator.userAgent, width: innerWidth })); + expect(otherNow).toEqual(desktop); + expect(otherNow.ua).not.toContain('iPhone'); + + await run('device', ['off']); + await page().reload({ waitUntil: 'domcontentloaded' }); + expect(await probe()).toEqual(desktop); + expect(await run('device')).toContain('none'); + }); + + test('--landscape swaps the orientation', async () => { + await freshTouchTab(null); + await run('device', ['Pixel 7', '--landscape']); + const p = await page().evaluate(() => ({ w: innerWidth, h: innerHeight })); + expect(p.w).toBeGreaterThan(p.h); + await run('device', ['off']); + }); + + test('viewport and responsive refuse while a device is active', async () => { + await freshTouchTab(); + await expect(run('viewport', ['800x600'])).rejects.toThrow('fix: $B device off'); + await expect(handleMetaCommand('responsive', [], bm, shutdown)).rejects.toThrow('fix: $B device off'); + expect((await probe()).width).toBe(393); + await run('device', ['off']); + }); + + test('full-page screenshot keeps the phone metrics', async () => { + await freshTouchTab(); + const out = path.join(os.tmpdir(), `touch-shot-${process.pid}.png`); + await handleMetaCommand('screenshot', [out], bm, shutdown); + fs.rmSync(out, { force: true }); + const p = await probe(); + expect(p.width).toBe(393); + expect(p.coarse).toBe(true); + await run('device', ['off']); + }); + + test('survives a useragent context rebuild; the first restored request carries the phone UA', async () => { + const id = await bm.newTab(baseUrl + '/echo'); + bm.switchTab(id, { bringToFront: false }); + await run('device', ['iPhone 15']); + await run('useragent', ['GstackDesktop/1.0']); + expect(await run('device')).toContain('iPhone 15'); + const echoed = JSON.parse(await page().evaluate(() => document.body.innerText)); + expect(echoed['user-agent']).toContain('iPhone'); + expect((await probe()).coarse).toBe(true); + await run('device', ['off']); + }); +}); + +describe('touch input', () => { + test('touch verbs need an active device', async () => { + await freshTouchTab(null); + await expect(run('tap', ['#fire'])).rejects.toThrow('BROWSE_TOUCH_NO_DEVICE'); + await expect(run('touch', ['down', '0', '10', '10'])).rejects.toThrow('fix: $B device "iPhone 15"'); + }); + + test('tap fires touchstart and touchend', async () => { + await freshTouchTab(); + await run('tap', ['#fire']); + expect(await data('#fire', 'shots')).toBe('1'); + const types = await page().evaluate(() => (window as any).__log.map((e: any) => e.type)); + expect(types).toContain('touchstart'); + expect(types).toContain('touchend'); + await run('tap', ['240', '440', '--hold', '50']); + expect(await data('#fire', 'shots')).toBe('2'); + }); + + test('swipe left advances the carousel; right goes back', async () => { + await freshTouchTab(); + await run('swipe', ['#carousel', 'left', '--distance', '200']); + expect(await data('#carousel', 'index')).toBe('1'); + await run('swipe', ['#carousel', 'right', '--distance', '200', '--ms', '100']); + expect(await data('#carousel', 'index')).toBe('0'); + }); + + test('pinch spreads above 1 and pinches below 1', async () => { + await freshTouchTab(); + await run('pinch', ['#zoom', '2']); + const spread = Number(await data('#zoom', 'scale')); + expect(spread).toBeGreaterThan(1.5); + await run('pinch', ['#zoom', '0.5']); + expect(Number(await data('#zoom', 'scale'))).toBeLessThan(spread * 0.7); + }); + + test('holding finger 0 while tapping finger 1 leaves finger 0 down', async () => { + await freshTouchTab(); + await run('touch', ['down', '0', '70', '440']); + await run('touch', ['down', '1', '240', '440']); + await run('touch', ['up', '1']); + expect(await data('#fire', 'shots')).toBe('1'); + expect(await data('#joystick', 'held')).toBe('true'); + expect(await page().evaluate(() => (window as any).__log.at(-1).touches)).toBe(1); + const held = await run('touch'); + expect(held).toContain('0'); + expect(held).not.toMatch(/\b1\b.*\(/); + await run('touch', ['move', '0', '90', '440']); + await run('touch', ['up', '0']); + expect(await data('#joystick', 'held')).toBe('false'); + expect(await run('touch')).toContain('none'); + }); + + test('usage errors name the problem and give a fix', async () => { + await freshTouchTab(); + await expect(run('touch', ['up', '3'])).rejects.toThrow(/finger 3 is not down[\s\S]*fix:/); + await expect(run('touch', ['down', '12', '1', '1'])).rejects.toThrow(/0-9[\s\S]*fix:/); + await expect(run('swipe', ['#carousel', 'sideways'])).rejects.toThrow(/up\|down\|left\|right[\s\S]*fix:/); + await expect(run('pinch', ['#zoom', '-1'])).rejects.toThrow(/scale[\s\S]*fix:/); + }); + + test('held fingers are cancelled on navigation and by touch cancel', async () => { + await freshTouchTab(); + await run('touch', ['down', '0', '70', '440']); + expect(await run('touch')).toContain('0'); + await page().goto(baseUrl + '/touch.html', { waitUntil: 'domcontentloaded' }); + expect(await run('touch')).toContain('none'); + await run('touch', ['down', '2', '240', '440']); + expect(await data('#fire', 'shots')).toBe('1'); + await run('touch', ['cancel']); + expect(await run('touch')).toContain('none'); + expect(await page().evaluate(() => (window as any).__log.at(-1).type)).toBe('touchcancel'); + }); +});