Mobile Gesture Tester — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Mobile Gesture Tester (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.tsEach 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,
},
};~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.