pramoddutta/mobile gesture tester
Test touch gestures including swipe, pinch-to-zoom, long press, and drag-and-drop on mobile viewports and touch-enabled devices.
npx skills add https://github.com/PramodDutta/qaskills --skill Mobile Gesture Tester
You are an expert QA automation engineer specializing in mobile gesture testing, touch interaction verification, and cross-device compatibility analysis. When asked to test touch gestures such as swipe, pinch-to-zoom, long press, drag-and-drop, and multi-touch interactions on mobile viewports and touch-enabled devices, follow these comprehensive instructions to systematically verify that every gesture-driven interface responds correctly.
Organize your mobile gesture test suite with this directory structure:
tests/
gestures/
swipe-carousel.spec.ts
pinch-to-zoom.spec.ts
long-press-context-menu.spec.ts
drag-and-drop.spec.ts
pull-to-refresh.spec.ts
scroll-gesture-conflicts.spec.ts
multi-touch-interactions.spec.ts
fixtures/
touch-device.fixture.ts
helpers/
gesture-simulator.ts
touch-event-builder.ts
gesture-validator.ts
device-profiles.ts
reports/
gesture-test-results.json
playwright.config.ts
appium.config.ts
Each spec file targets a specific gesture type. The helpers directory contains utilities for constructing realistic touch event sequences and validating gesture outcomes.
The foundation of mobile gesture testing is a reliable simulator that produces realistic touch event sequences with configurable speed, trajectory, and multi-touch points.
// helpers/gesture-simulator.ts
import { Page } from '@playwright/test';
export interface Point {
x: number;
y: number;
}
export interface GestureOptions {
durationMs?: number;
steps?: number;
startDelay?: number;
}
export class GestureSimulator {
constructor(private page: Page) {}
async swipe(from: Point, to: Point, options: GestureOptions = {}): Promise<void> {
const { durationMs = 300, steps = 20, startDelay = 50 } = options;
const stepDuration = durationMs / steps;
// Dispatch touchstart at the origin point
await this.page.evaluate(
({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({
identifier: 0,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [touch],
targetTouches: [touch],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
);
},
{ x: from.x, y: from.y }
);
await this.page.waitForTimeout(startDelay);
// Move through intermediate points with easing
for (let i = 1; i <= steps; i++) {
const progress = i / steps;
const easedProgress = this.easeOutCubic(progress);
const currentX = from.x + (to.x - from.x) * easedProgress;
const currentY = from.y + (to.y - from.y) * easedProgress;
await this.page.evaluate(
({ x, y, originX, originY }) => {
const target = document.elementFromPoint(originX, originY) || document.body;
const touch = new Touch({
identifier: 0,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [touch],
targetTouches: [touch],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
);
},
{ x: currentX, y: currentY, originX: from.x, originY: from.y }
);
await this.page.waitForTimeout(stepDuration);
}
// Dispatch touchend at the destination point
await this.page.evaluate(
({ x, y, originX, originY }) => {
const target = document.elementFromPoint(originX, originY) || document.body;
const touch = new Touch({
identifier: 0,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
);
},
{ x: to.x, y: to.y, originX: from.x, originY: from.y }
);
}
async pinch(
center: Point,
startDistance: number,
endDistance: number,
options: GestureOptions = {}
): Promise<void> {
const { durationMs = 500, steps = 30 } = options;
const stepDuration = durationMs / steps;
for (let i = 0; i <= steps; i++) {
const progress = i / steps;
const easedProgress = this.easeOutCubic(progress);
const currentDistance = startDistance + (endDistance - startDistance) * easedProgress;
const finger1 = { x: center.x - currentDistance / 2, y: center.y };
const finger2 = { x: center.x + currentDistance / 2, y: center.y };
const eventType = i === 0 ? 'touchstart' : i === steps ? 'touchend' : 'touchmove';
await this.page.evaluate(
({ f1, f2, type, cx, cy }) => {
const target = document.elementFromPoint(cx, cy) || document.body;
const touch1 = new Touch({
identifier: 0,
target,
clientX: f1.x,
clientY: f1.y,
pageX: f1.x,
pageY: f1.y,
});
const touch2 = new Touch({
identifier: 1,
target,
clientX: f2.x,
clientY: f2.y,
pageX: f2.x,
pageY: f2.y,
});
const touches = type === 'touchend' ? [] : [touch1, touch2];
target.dispatchEvent(
new TouchEvent(type, {
touches,
targetTouches: touches,
changedTouches: [touch1, touch2],
bubbles: true,
cancelable: true,
})
);
},
{ f1: finger1, f2: finger2, type: eventType, cx: center.x, cy: center.y }
);
await this.page.waitForTimeout(stepDuration);
}
}
async longPress(point: Point, holdMs: number = 800): Promise<void> {
await this.page.evaluate(
({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({
identifier: 0, target,
clientX: x, clientY: y, pageX: x, pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [touch], targetTouches: [touch], changedTouches: [touch],
bubbles: true, cancelable: true,
})
);
},
{ x: point.x, y: point.y }
);
await this.page.waitForTimeout(holdMs);
await this.page.evaluate(
({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({
identifier: 0, target,
clientX: x, clientY: y, pageX: x, pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [], targetTouches: [], changedTouches: [touch],
bubbles: true, cancelable: true,
})
);
},
{ x: point.x, y: point.y }
);
}
async drag(
from: Point,
to: Point,
options: GestureOptions & { holdBeforeDragMs?: number } = {}
): Promise<void> {
const { holdBeforeDragMs = 150 } = options;
await this.page.evaluate(
({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({
identifier: 0, target,
clientX: x, clientY: y, pageX: x, pageY: y,
});
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [touch], targetTouches: [touch], changedTouches: [touch],
bubbles: true, cancelable: true,
})
);
},
{ x: from.x, y: from.y }
);
await this.page.waitForTimeout(holdBeforeDragMs);
await this.swipe(from, to, { ...options, startDelay: 0 });
}
async pullToRefresh(page: Page, pullDistance: number = 150): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('No viewport size');
const startY = 100;
const endY = startY + pullDistance;
const centerX = viewport.width / 2;
await this.swipe(
{ x: centerX, y: startY },
{ x: centerX, y: endY },
{ durationMs: 400, steps: 25 }
);
}
private easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
}
Different devices have different screen sizes, pixel ratios, and touch capabilities.
// helpers/device-profiles.ts
export interface DeviceProfile {
name: string;
viewport: { width: number; height: number };
deviceScaleFactor: number;
isMobile: boolean;
hasTouch: boolean;
userAgent: string;
}
export const deviceProfiles: Record<string, DeviceProfile> = {
'iphone-14': {
name: 'iPhone 14',
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
},
'pixel-7': {
name: 'Pixel 7',
viewport: { width: 412, height: 915 },
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36',
},
'ipad-pro-12': {
name: 'iPad Pro 12.9"',
viewport: { width: 1024, height: 1366 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
},
'galaxy-fold': {
name: 'Samsung Galaxy Z Fold',
viewport: { width: 280, height: 653 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (Linux; Android 13; SM-F946B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36',
},
'galaxy-s23': {
name: 'Samsung Galaxy S23',
viewport: { width: 360, height: 780 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36',
},
};
Swipe is the most common mobile gesture, used in carousels, dismiss actions, navigation drawers, and card stacks.
// tests/gestures/swipe-carousel.spec.ts
import { test, expect } from '@playwright/test';
import { GestureSimulator } from '../helpers/gesture-simulator';
import { deviceProfiles } from '../helpers/device-profiles';
const mobile = deviceProfiles['iphone-14'];
test.describe('Swipe Carousel Gestures', () => {
test.use({
viewport: mobile.viewport,
isMobile: mobile.isMobile,
hasTouch: mobile.hasTouch,
});
test('horizontal swipe advances carousel to next slide', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/');
const carousel = page.locator('[data-testid="carousel"]');
await carousel.waitFor({ state: 'visible' });
const box = await carousel.boundingBox();
if (!box) throw new Error('Carousel not found');
const initialSlide = await page
.locator('[data-testid="carousel-indicator"].active, [aria-current="true"]')
.getAttribute('data-index');
// Swipe left to advance
await gestures.swipe(
{ x: box.x + box.width * 0.8, y: box.y + box.height / 2 },
{ x: box.x + box.width * 0.2, y: box.y + box.height / 2 },
{ durationMs: 300 }
);
await page.waitForTimeout(500);
const newSlide = await page
.locator('[data-testid="carousel-indicator"].active, [aria-current="true"]')
.getAttribute('data-index');
expect(newSlide).not.toBe(initialSlide);
});
test('swipe does not go past the last slide', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/');
const carousel = page.locator('[data-testid="carousel"]');
await carousel.waitFor({ state: 'visible' });
const box = await carousel.boundingBox();
if (!box) throw new Error('Carousel not found');
const totalSlides = await page.locator('[data-testid="carousel-indicator"]').count();
// Swipe far beyond the last slide
for (let i = 0; i < totalSlides + 3; i++) {
await gestures.swipe(
{ x: box.x + box.width * 0.8, y: box.y + box.height / 2 },
{ x: box.x + box.width * 0.2, y: box.y + box.height / 2 },
{ durationMs: 250 }
);
await page.waitForTimeout(400);
}
const activeIndex = await page
.locator('[data-testid="carousel-indicator"].active, [aria-current="true"]')
.getAttribute('data-index');
expect(Number(activeIndex)).toBeLessThanOrEqual(totalSlides - 1);
});
test('short swipe distance snaps back to current slide', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/');
const carousel = page.locator('[data-testid="carousel"]');
await carousel.waitFor({ state: 'visible' });
const box = await carousel.boundingBox();
if (!box) throw new Error('Carousel not found');
const initialSlide = await page
.locator('[data-testid="carousel-indicator"].active, [aria-current="true"]')
.getAttribute('data-index');
// Small swipe that should not trigger transition
await gestures.swipe(
{ x: box.x + box.width * 0.55, y: box.y + box.height / 2 },
{ x: box.x + box.width * 0.45, y: box.y + box.height / 2 },
{ durationMs: 300 }
);
await page.waitForTimeout(500);
const afterSlide = await page
.locator('[data-testid="carousel-indicator"].active, [aria-current="true"]')
.getAttribute('data-index');
expect(afterSlide).toBe(initialSlide);
});
test('vertical swipe on carousel allows page scroll', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/');
const carousel = page.locator('[data-testid="carousel"]');
await carousel.waitFor({ state: 'visible' });
const box = await carousel.boundingBox();
if (!box) throw new Error('Carousel not found');
const initialScrollY = await page.evaluate(() => window.scrollY);
await gestures.swipe(
{ x: box.x + box.width / 2, y: box.y + box.height * 0.8 },
{ x: box.x + box.width / 2, y: box.y + box.height * 0.2 },
{ durationMs: 300 }
);
await page.waitForTimeout(500);
const newScrollY = await page.evaluate(() => window.scrollY);
expect(newScrollY).toBeGreaterThan(initialScrollY);
});
test('swipe-to-dismiss removes item from list', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/notifications');
const items = page.locator('[data-testid="notification-item"]');
const initialCount = await items.count();
if (initialCount === 0) return;
const firstItem = items.first();
const box = await firstItem.boundingBox();
if (!box) throw new Error('Notification item not found');
// Swipe right to dismiss
await gestures.swipe(
{ x: box.x + 20, y: box.y + box.height / 2 },
{ x: box.x + box.width + 50, y: box.y + box.height / 2 },
{ durationMs: 250 }
);
await page.waitForTimeout(500);
const afterCount = await items.count();
expect(afterCount).toBe(initialCount - 1);
});
});
// tests/gestures/pinch-to-zoom.spec.ts
import { test, expect } from '@playwright/test';
import { GestureSimulator } from '../helpers/gesture-simulator';
import { deviceProfiles } from '../helpers/device-profiles';
test.describe('Pinch-to-Zoom Gestures', () => {
test.use({
viewport: deviceProfiles['iphone-14'].viewport,
isMobile: true,
hasTouch: true,
});
test('pinch-out zooms in on image', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/gallery');
const image = page.locator('[data-testid="zoomable-image"]').first();
await image.waitFor({ state: 'visible' });
const box = await image.boundingBox();
if (!box) throw new Error('Zoomable image not found');
const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const initialScale = await image.evaluate((el) => {
const transform = window.getComputedStyle(el).transform;
if (transform === 'none') return 1;
const matrix = transform.match(/matrix\((.+)\)/);
return matrix ? parseFloat(matrix[1].split(', ')[0]) : 1;
});
// Pinch out: spread fingers apart
await gestures.pinch(center, 50, 200, { durationMs: 500 });
await page.waitForTimeout(300);
const afterScale = await image.evaluate((el) => {
const transform = window.getComputedStyle(el).transform;
if (transform === 'none') return 1;
const matrix = transform.match(/matrix\((.+)\)/);
return matrix ? parseFloat(matrix[1].split(', ')[0]) : 1;
});
expect(afterScale).toBeGreaterThan(initialScale);
});
test('zoom does not exceed maximum scale limit', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/gallery');
const image = page.locator('[data-testid="zoomable-image"]').first();
await image.waitFor({ state: 'visible' });
const box = await image.boundingBox();
if (!box) throw new Error('Zoomable image not found');
const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
for (let i = 0; i < 10; i++) {
await gestures.pinch(center, 30, 300, { durationMs: 300 });
await page.waitForTimeout(200);
}
const finalScale = await image.evaluate((el) => {
const transform = window.getComputedStyle(el).transform;
if (transform === 'none') return 1;
const matrix = transform.match(/matrix\((.+)\)/);
return matrix ? parseFloat(matrix[1].split(', ')[0]) : 1;
});
// Should be capped at a reasonable maximum
expect(finalScale).toBeLessThanOrEqual(5);
});
test('double-tap toggles zoom in and out', async ({ page }) => {
await page.goto('/gallery');
const image = page.locator('[data-testid="zoomable-image"]').first();
await image.waitFor({ state: 'visible' });
const box = await image.boundingBox();
if (!box) throw new Error('Zoomable image not found');
const tapPoint = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
// Double-tap to zoom in
await page.touchscreen.tap(tapPoint.x, tapPoint.y);
await page.waitForTimeout(100);
await page.touchscreen.tap(tapPoint.x, tapPoint.y);
await page.waitForTimeout(500);
const zoomedScale = await image.evaluate((el) => {
const transform = window.getComputedStyle(el).transform;
if (transform === 'none') return 1;
const matrix = transform.match(/matrix\((.+)\)/);
return matrix ? parseFloat(matrix[1].split(', ')[0]) : 1;
});
expect(zoomedScale).toBeGreaterThan(1);
// Double-tap again to zoom out
await page.touchscreen.tap(tapPoint.x, tapPoint.y);
await page.waitForTimeout(100);
await page.touchscreen.tap(tapPoint.x, tapPoint.y);
await page.waitForTimeout(500);
const resetScale = await image.evaluate((el) => {
const transform = window.getComputedStyle(el).transform;
if (transform === 'none') return 1;
const matrix = transform.match(/matrix\((.+)\)/);
return matrix ? parseFloat(matrix[1].split(', ')[0]) : 1;
});
expect(resetScale).toBeCloseTo(1, 1);
});
});
// tests/gestures/long-press-context-menu.spec.ts
import { test, expect } from '@playwright/test';
import { GestureSimulator } from '../helpers/gesture-simulator';
import { deviceProfiles } from '../helpers/device-profiles';
test.describe('Long Press Context Menu', () => {
test.use({
viewport: deviceProfiles['iphone-14'].viewport,
isMobile: true,
hasTouch: true,
});
test('long press on list item shows context menu', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/list');
const listItem = page.locator('[data-testid="list-item"]').first();
await listItem.waitFor({ state: 'visible' });
const box = await listItem.boundingBox();
if (!box) throw new Error('List item not found');
await gestures.longPress(
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
800
);
await page.waitForTimeout(300);
const contextMenu = page.locator('[data-testid="context-menu"], [role="menu"]');
await expect(contextMenu).toBeVisible();
const menuItems = contextMenu.locator('[role="menuitem"], .menu-item');
expect(await menuItems.count()).toBeGreaterThan(0);
});
test('quick tap does not trigger long press action', async ({ page }) => {
await page.goto('/list');
const listItem = page.locator('[data-testid="list-item"]').first();
await listItem.waitFor({ state: 'visible' });
await listItem.tap();
await page.waitForTimeout(300);
const contextMenu = page.locator('[data-testid="context-menu"], [role="menu"]');
await expect(contextMenu).not.toBeVisible();
});
test('finger movement during long press cancels the gesture', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/list');
const listItem = page.locator('[data-testid="list-item"]').first();
await listItem.waitFor({ state: 'visible' });
const box = await listItem.boundingBox();
if (!box) throw new Error('List item not found');
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
// Begin touch
await page.evaluate(({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({ identifier: 0, target, clientX: x, clientY: y, pageX: x, pageY: y });
target.dispatchEvent(new TouchEvent('touchstart', {
touches: [touch], targetTouches: [touch], changedTouches: [touch], bubbles: true, cancelable: true,
}));
}, start);
await page.waitForTimeout(300);
// Move finger significantly before long press threshold
await page.evaluate(({ x, y, ox, oy }) => {
const target = document.elementFromPoint(ox, oy) || document.body;
const touch = new Touch({ identifier: 0, target, clientX: x, clientY: y, pageX: x, pageY: y });
target.dispatchEvent(new TouchEvent('touchmove', {
touches: [touch], targetTouches: [touch], changedTouches: [touch], bubbles: true, cancelable: true,
}));
}, { x: start.x + 50, y: start.y + 50, ox: start.x, oy: start.y });
await page.waitForTimeout(600);
await page.evaluate(({ x, y }) => {
const target = document.elementFromPoint(x, y) || document.body;
const touch = new Touch({ identifier: 0, target, clientX: x, clientY: y, pageX: x, pageY: y });
target.dispatchEvent(new TouchEvent('touchend', {
touches: [], targetTouches: [], changedTouches: [touch], bubbles: true, cancelable: true,
}));
}, { x: start.x + 50, y: start.y + 50 });
await page.waitForTimeout(300);
const contextMenu = page.locator('[data-testid="context-menu"], [role="menu"]');
await expect(contextMenu).not.toBeVisible();
});
});
// tests/gestures/drag-and-drop.spec.ts
import { test, expect } from '@playwright/test';
import { GestureSimulator } from '../helpers/gesture-simulator';
import { deviceProfiles } from '../helpers/device-profiles';
test.describe('Touch Drag and Drop', () => {
test.use({
viewport: deviceProfiles['ipad-pro-12'].viewport,
isMobile: true,
hasTouch: true,
});
test('drag card from one column to another', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/kanban');
const draggable = page.locator('[data-testid="draggable-card"]').first();
const dropZone = page.locator('[data-testid="drop-zone-done"]');
await draggable.waitFor({ state: 'visible' });
await dropZone.waitFor({ state: 'visible' });
const dragBox = await draggable.boundingBox();
const dropBox = await dropZone.boundingBox();
if (!dragBox || !dropBox) throw new Error('Elements not found');
const initialCount = await dropZone.locator('[data-testid="draggable-card"]').count();
await gestures.drag(
{ x: dragBox.x + dragBox.width / 2, y: dragBox.y + dragBox.height / 2 },
{ x: dropBox.x + dropBox.width / 2, y: dropBox.y + dropBox.height / 2 },
{ durationMs: 600, holdBeforeDragMs: 200 }
);
await page.waitForTimeout(500);
const afterCount = await dropZone.locator('[data-testid="draggable-card"]').count();
expect(afterCount).toBe(initialCount + 1);
});
test('dragging outside valid zones returns item to origin', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/kanban');
const draggable = page.locator('[data-testid="draggable-card"]').first();
await draggable.waitFor({ state: 'visible' });
const dragBox = await draggable.boundingBox();
if (!dragBox) throw new Error('Draggable not found');
const parentColumn = draggable.locator('..');
const initialColumnCount = await parentColumn.locator('[data-testid="draggable-card"]').count();
// Drag to an invalid area (far outside any drop zone)
await gestures.drag(
{ x: dragBox.x + dragBox.width / 2, y: dragBox.y + dragBox.height / 2 },
{ x: 10, y: 10 },
{ durationMs: 500, holdBeforeDragMs: 200 }
);
await page.waitForTimeout(500);
// Item should return to its original column
const afterColumnCount = await parentColumn.locator('[data-testid="draggable-card"]').count();
expect(afterColumnCount).toBe(initialColumnCount);
});
});
// tests/gestures/pull-to-refresh.spec.ts
import { test, expect } from '@playwright/test';
import { GestureSimulator } from '../helpers/gesture-simulator';
import { deviceProfiles } from '../helpers/device-profiles';
test.describe('Pull-to-Refresh Gesture', () => {
test.use({
viewport: deviceProfiles['iphone-14'].viewport,
isMobile: true,
hasTouch: true,
});
test('pulling down at top of page triggers refresh', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.goto('/feed');
await page.waitForLoadState('networkidle');
let refreshTriggered = false;
await page.route('**/api/feed**', async (route) => {
refreshTriggered = true;
await route.continue();
});
// Ensure we are at the top of the page
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(200);
// Pull down gesture
await gestures.pullToRefresh(page, 150);
await page.waitForTimeout(2000);
expect(refreshTriggered).toBe(true);
});
test('pull-to-refresh shows loading indicator during refresh', async ({ page }) => {
const gestures = new GestureSimulator(page);
await page.route('**/api/feed**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await page.goto('/feed');
await page.waitForLoadState('networkidle');
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(200);
await gestures.pullToRefresh(page, 150);
await page.waitForTimeout(500);
// A refresh indicator should be visible
const refreshIndicator = page.locator(
'[data-testid="pull-refresh-indicator"], .refresh-spinner, .pull-to-refresh-loading'
);
const isVisible = await refreshIndicator.isVisible().catch(() => false);
// Some implementations use the native indicator, so just verify no error
expect(typeof isVisible).toBe('boolean');
});
});
// playwright.config.ts
import { defineConfig, devices as playwrightDevices } from '@playwright/test';
export default defineConfig({
testDir: './tests/gestures',
timeout: 45000,
retries: 2,
use: {
screenshot: 'on',
video: 'on',
trace: 'on-first-retry',
},
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'reports/gesture-test-results.json' }],
],
projects: [
{ name: 'iphone-14', use: { ...playwrightDevices['iPhone 14'] } },
{ name: 'pixel-7', use: { ...playwrightDevices['Pixel 7'] } },
{ name: 'ipad-pro', use: { ...playwrightDevices['iPad Pro 11'] } },
{
name: 'galaxy-fold',
use: {
viewport: { width: 280, height: 653 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 3,
},
},
],
});
// appium.config.ts
export const appiumCapabilities = {
android: {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Pixel 7',
'appium:platformVersion': '13.0',
'appium:browserName': 'Chrome',
'appium:newCommandTimeout': 240,
},
ios: {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:deviceName': 'iPhone 14',
'appium:platformVersion': '16.0',
'appium:browserName': 'Safari',
'appium:newCommandTimeout': 240,
},
};
10. Test gesture interruption scenarios. Phone calls mid-swipe, keyboard appearance during drag, and device rotation during pinch-zoom must not leave broken UI state.
11. Clean up gesture state after each test. Zoom levels, scroll positions, and drag states can persist between tests. Reset to prevent interdependence.
12. Test rapid successive gestures. Users swipe multiple times quickly through carousels. Verify no animation queue buildup or missed inputs.
10. Only testing in Chrome. Safari on iOS handles touch events differently from Chrome on Android. Test on both platforms.
10. Test on actual hardware. Emulators miss physical touch issues like palm rejection, edge gesture conflicts with OS-level gestures, and device-specific haptic feedback behavior.
Take pramoddutta/mobile gesture tester from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.