test(browse): failing-first device and touch tests (GSTA-227 F3, #3058)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 23:07:37 +00:00
1 parent 4eff47c5cf
commit 48d5f0ce3e
2 files changed
+293

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Touch fixture</title>
<style>
body { margin: 0; font-family: sans-serif; touch-action: none; }
#carousel, #zoom, #joystick, #fire { position: absolute; display: flex; align-items: center; justify-content: center; }
#carousel { left: 10px; top: 10px; width: 300px; height: 120px; background: #def; }
#zoom { left: 10px; top: 150px; width: 300px; height: 200px; background: #fed; }
#joystick { left: 10px; top: 380px; width: 120px; height: 120px; background: #dfd; border-radius: 60px; }
#fire { left: 200px; top: 400px; width: 80px; height: 80px; background: #fdd; border-radius: 40px; }
</style>
</head>
<body>
<div id="carousel" data-index="0">item 0</div>
<div id="zoom" data-scale="1">zoom 1</div>
<div id="joystick" data-held="false">joystick</div>
<button id="fire" data-shots="0">fire</button>
<script>
window.__log = [];
for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
document.addEventListener(type, (e) => {
window.__log.push({ type, touches: e.touches.length, changed: e.changedTouches.length, target: e.target.id || e.target.tagName });
}, { passive: false });
}
const carousel = document.getElementById('carousel');
let swipeStart = null;
carousel.addEventListener('touchstart', (e) => { swipeStart = { x: e.touches[0].clientX }; carousel.dataset.lastX = String(swipeStart.x); });
carousel.addEventListener('touchmove', (e) => { carousel.dataset.lastX = String(e.touches[0].clientX); });
carousel.addEventListener('touchend', () => {
if (!swipeStart) return;
const dx = Number(carousel.dataset.lastX) - swipeStart.x;
let index = Number(carousel.dataset.index);
if (dx < -50) index += 1;
if (dx > 50) index -= 1;
carousel.dataset.index = String(index);
carousel.textContent = 'item ' + index;
swipeStart = null;
});
const zoom = document.getElementById('zoom');
let pinchStart = null;
const spread = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
zoom.addEventListener('touchmove', (e) => {
if (e.touches.length < 2) return;
if (pinchStart === null) pinchStart = { distance: spread(e.touches), scale: Number(zoom.dataset.scale) };
const scale = pinchStart.scale * spread(e.touches) / pinchStart.distance;
zoom.dataset.scale = scale.toFixed(2);
zoom.textContent = 'zoom ' + zoom.dataset.scale;
});
zoom.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinchStart = null; });
const joystick = document.getElementById('joystick');
const fire = document.getElementById('fire');
joystick.addEventListener('touchstart', (e) => { joystick.dataset.touchId = String(e.changedTouches[0].identifier); joystick.dataset.held = 'true'; });
document.addEventListener('touchend', (e) => {
for (const t of e.changedTouches) if (String(t.identifier) === joystick.dataset.touchId) joystick.dataset.held = 'false';
});
fire.addEventListener('touchstart', () => { fire.dataset.shots = String(Number(fire.dataset.shots) + 1); });
</script>
</body>
</html>
+229
View File
@@ -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<typeof startTestServer>;
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<number> {
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');
});
});