mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
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:
1 parent
4eff47c5cf
commit
48d5f0ce3e
2 files changed
+293
No files matched your search
Vendored
+64
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user