Files
2026-08-12 21:01:45 +03:00

1328 lines
40 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { instrumentPage, setActiveClickRecorder } from '../src/instrument.js'
import { changeFocus } from '../src/changeFocus.js'
import { EventRecorder } from '../src/events.js'
import type {
FocusChangeEvent,
InputEvent,
MouseMoveEvent,
MouseDownEvent,
MouseUpEvent,
MouseHideEvent,
MouseWaitEvent,
} from '../src/events.js'
import type { Locator, Page } from '@playwright/test'
const __dirname = dirname(fileURLToPath(import.meta.url))
const fixtureHtml = readFileSync(
resolve(__dirname, 'fixtures/index.html'),
'utf-8'
)
type CursorMoveOpts = {
move?: (
{ duration?: number; speed?: never } | { duration?: never; speed?: number }
) & {
easing?: string
curve?: 'none' | 'natural' | 'arc' | [number, number, number, number]
curviness?: number
delayAfter?: number
}
}
function checkLocator(locator: Locator): {
check(opts?: Parameters<Locator['check']>[0] & CursorMoveOpts): Promise<void>
} {
return locator as never
}
function uncheckLocator(locator: Locator): {
uncheck(
opts?: Parameters<Locator['uncheck']>[0] & CursorMoveOpts
): Promise<void>
} {
return locator as never
}
function tapLocator(locator: Locator): {
tap(opts?: Parameters<Locator['tap']>[0] & CursorMoveOpts): Promise<void>
} {
return locator as never
}
function clickableLocator(locator: Locator): {
click(opts?: Parameters<Locator['click']>[0] & CursorMoveOpts): Promise<void>
} {
return locator as never
}
function fillableLocator(locator: Locator): {
fill(
value: string,
opts?: CursorMoveOpts & {
duration?: number
timeout?: number
forceClick?: boolean
hideMouse?: boolean
}
): Promise<void>
} {
return locator as never
}
function typeableLocator(locator: Locator): {
pressSequentially(
text: string,
opts?: Parameters<Locator['pressSequentially']>[1] &
CursorMoveOpts & {
forceClick?: boolean
hideMouse?: boolean
}
): Promise<void>
} {
return locator as never
}
function selectableLocator(locator: Locator): {
selectOption(
values: Parameters<Locator['selectOption']>[0],
opts?: Parameters<Locator['selectOption']>[1] & CursorMoveOpts
): Promise<string[]>
} {
return locator as never
}
// ---------------------------------------------------------------------------
// Shared setup helpers
// ---------------------------------------------------------------------------
let recorder: EventRecorder
type PointerMoveEvent = MouseMoveEvent | FocusChangeEvent
function isPointerMoveEvent(
event: InputEvent['events'][number]
): event is PointerMoveEvent {
return event.type === 'mouseMove' || event.type === 'focusChange'
}
function moveStartMs(event: PointerMoveEvent): number {
return event.type === 'mouseMove'
? event.startMs
: (event.mouse?.startMs ?? 0)
}
function moveEndMs(event: PointerMoveEvent): number {
return event.type === 'mouseMove' ? event.endMs : (event.mouse?.endMs ?? 0)
}
function moveEventsIn(event: InputEvent): PointerMoveEvent[] {
return event.events.filter(isPointerMoveEvent)
}
function inputEvents(): InputEvent[] {
return recorder.getEvents().filter((e): e is InputEvent => e.type === 'input')
}
function clickEvents(): InputEvent[] {
return recorder
.getEvents()
.filter((e): e is InputEvent => e.type === 'input' && e.subType === 'click')
}
function mouseMoveEvents(): PointerMoveEvent[] {
return inputEvents()
.filter((e) => e.subType === 'mouseMove' || e.subType === 'focusChange')
.flatMap((e) => e.events.filter(isPointerMoveEvent))
}
function mouseHideEventsIn(event: InputEvent): MouseHideEvent[] {
return event.events.filter((e): e is MouseHideEvent => e.type === 'mouseHide')
}
function inputElementRect(event: InputEvent) {
return event.events.find(
(inner): inner is MouseMoveEvent | FocusChangeEvent =>
(inner.type === 'mouseMove' || inner.type === 'focusChange') &&
inner.elementRect !== undefined
)?.elementRect
}
type InstrumentedMouse = {
move(
x: number,
y: number,
options?: {
steps?: number
duration?: number
speed?: number
easing?: string
}
): Promise<void>
down(options?: {
duration?: number
easing?: string
fake?: boolean
}): Promise<void>
up(options?: {
duration?: number
easing?: string
fake?: boolean
}): Promise<void>
click(
x: number,
y: number,
options?: {
move?: { duration?: number; easing?: string; delayAfter?: number }
duration?: number
fake?: boolean
}
): Promise<void>
dblclick(
x: number,
y: number,
options?: {
move?: { duration?: number; easing?: string; delayAfter?: number }
duration?: number
fake?: boolean
}
): Promise<void>
}
async function scrollY(page: Page) {
return page.evaluate(() => window.scrollY)
}
test.beforeEach(async ({ page }) => {
await instrumentPage(page)
await page.setContent(fixtureHtml)
recorder = new EventRecorder()
recorder.start()
setActiveClickRecorder(recorder)
})
test.afterEach(() => {
setActiveClickRecorder(null)
})
// ---------------------------------------------------------------------------
// click
// ---------------------------------------------------------------------------
test.describe('click instrumentation', () => {
test('records a click event', async ({ page }) => {
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 50 },
})
const events = clickEvents()
expect(events).toHaveLength(1)
const [event] = events
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('actually clicks the button', async ({ page }) => {
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 50 },
})
await expect(page.locator('#click-status')).not.toHaveText('Not clicked')
})
test('records cubic-bezier control points for a curved move', async ({
page,
}) => {
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 200, curve: 'natural' },
})
const [event] = clickEvents()
const move = moveEventsIn(event!)[0] as FocusChangeEvent
expect(move.mouse?.control).toBeDefined()
expect(move.mouse!.control).toHaveLength(2)
const [c1, c2] = move.mouse!.control!
expect(typeof c1.x).toBe('number')
expect(typeof c2.y).toBe('number')
})
test('omits control points for a straight move', async ({ page }) => {
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 200, curve: 'none' },
})
const [event] = clickEvents()
const move = moveEventsIn(event!)[0] as FocusChangeEvent
expect(move.mouse?.control).toBeUndefined()
})
test('records elementRect', async ({ page }) => {
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 50 },
})
const events = clickEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
test('scrolls into view before clicking off-screen element', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await clickableLocator(page.locator('#offscreen-click-button')).click({
move: { duration: 50 },
})
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('does not scroll when clicking an already-visible element', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 50 },
})
// The button is already on screen, so a plain click must not move the page.
expect(await scrollY(page)).toBe(0)
})
test('actually clicks an off-screen button after scrolling', async ({
page,
}) => {
await clickableLocator(page.locator('#offscreen-click-button')).click({
move: { duration: 50 },
})
await expect(page.locator('#offscreen-click-status')).not.toHaveText(
'Not clicked'
)
})
})
// ---------------------------------------------------------------------------
// fill
// ---------------------------------------------------------------------------
test.describe('fill instrumentation', () => {
test('records an input event with subType pressSequentially', async ({
page,
}) => {
await fillableLocator(page.locator('#text-input')).fill('hi', {
duration: 100,
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('pressSequentially')
})
test('actually fills the input', async ({ page }) => {
await fillableLocator(page.locator('#text-input')).fill('hello', {
duration: 100,
})
await expect(page.locator('#text-input')).toHaveValue('hello')
})
test('records elementRect', async ({ page }) => {
await fillableLocator(page.locator('#text-input')).fill('x', {
duration: 50,
})
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
test('scrolls into view before filling off-screen input', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await fillableLocator(page.locator('#offscreen-text-input')).fill(
'scroll test',
{ duration: 100 }
)
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually fills an off-screen input after scrolling', async ({
page,
}) => {
await fillableLocator(page.locator('#offscreen-text-input')).fill('works', {
duration: 100,
})
await expect(page.locator('#offscreen-text-input')).toHaveValue('works')
})
test('with click option: records click sub-events', async ({ page }) => {
await fillableLocator(page.locator('#text-input')).fill('hi', {
duration: 100,
move: { duration: 50 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('pressSequentially')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('hideMouse: true hides the cursor during fill', async ({ page }) => {
await fillableLocator(page.locator('#text-input')).fill('hi', {
duration: 100,
hideMouse: true,
})
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(1)
})
test('hideMouse: false does not hide the cursor during fill', async ({
page,
}) => {
await fillableLocator(page.locator('#text-input')).fill('hi', {
duration: 100,
hideMouse: false,
})
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(0)
})
test('does not hide the cursor by default', async ({ page }) => {
await fillableLocator(page.locator('#text-input')).fill('hi', {
duration: 100,
})
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// pressSequentially
// ---------------------------------------------------------------------------
test.describe('pressSequentially instrumentation', () => {
test('records an input event with subType pressSequentially', async ({
page,
}) => {
await page.locator('#text-input').pressSequentially('hi', { delay: 30 })
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('pressSequentially')
})
test('actually types into the input', async ({ page }) => {
await page.locator('#text-input').pressSequentially('abc', { delay: 30 })
await expect(page.locator('#text-input')).toHaveValue('abc')
})
test('records elementRect', async ({ page }) => {
await page.locator('#text-input').pressSequentially('x', { delay: 30 })
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
})
test('scrolls into view before typing in off-screen input', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await page
.locator('#offscreen-text-input')
.pressSequentially('scroll', { delay: 30 })
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually types into an off-screen input after scrolling', async ({
page,
}) => {
await page
.locator('#offscreen-text-input')
.pressSequentially('done', { delay: 30 })
await expect(page.locator('#offscreen-text-input')).toHaveValue('done')
})
test('with click option: records click sub-events', async ({ page }) => {
await typeableLocator(page.locator('#text-input')).pressSequentially('hi', {
delay: 30,
move: { duration: 50 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('pressSequentially')
const move = moveEventsIn(event!)[0]!
expect(move).toBeDefined()
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
})
test('hideMouse: true hides the cursor during pressSequentially', async ({
page,
}) => {
await typeableLocator(page.locator('#text-input')).pressSequentially('hi', {
delay: 30,
hideMouse: true,
})
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(1)
})
test('hideMouse: false does not hide the cursor during pressSequentially', async ({
page,
}) => {
await typeableLocator(page.locator('#text-input')).pressSequentially('hi', {
delay: 30,
hideMouse: false,
})
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(0)
})
test('does not hide the cursor by default', async ({ page }) => {
await page.locator('#text-input').pressSequentially('hi', { delay: 30 })
const events = inputEvents()
const [event] = events
expect(mouseHideEventsIn(event!)).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------
test.describe('check instrumentation', () => {
test('records an input event with subType check', async ({ page }) => {
await checkLocator(page.locator('#checkbox-unchecked')).check()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('check')
})
test('actually checks the checkbox', async ({ page }) => {
await checkLocator(page.locator('#checkbox-unchecked')).check()
await expect(page.locator('#checkbox-unchecked')).toBeChecked()
})
test('records elementRect', async ({ page }) => {
await checkLocator(page.locator('#checkbox-unchecked')).check()
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
test('animates the cursor and records sub-events by default', async ({
page,
}) => {
await checkLocator(page.locator('#checkbox-unchecked')).check()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('check')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('scrolls into view before checking off-screen checkbox', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await checkLocator(page.locator('#offscreen-checkbox-unchecked')).check()
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually checks an off-screen checkbox after scrolling', async ({
page,
}) => {
await checkLocator(page.locator('#offscreen-checkbox-unchecked')).check()
await expect(page.locator('#offscreen-checkbox-unchecked')).toBeChecked()
})
test('with click option: animates cursor and records sub-events', async ({
page,
}) => {
await checkLocator(page.locator('#checkbox-unchecked')).check({
move: { duration: 100 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('check')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('with click option: actually checks the checkbox', async ({ page }) => {
await checkLocator(page.locator('#checkbox-unchecked')).check({
move: { duration: 50 },
})
await expect(page.locator('#checkbox-unchecked')).toBeChecked()
})
})
// ---------------------------------------------------------------------------
// uncheck
// ---------------------------------------------------------------------------
test.describe('uncheck instrumentation', () => {
test('records an input event with subType uncheck', async ({ page }) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('uncheck')
})
test('actually unchecks the checkbox', async ({ page }) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck()
await expect(page.locator('#checkbox-checked')).not.toBeChecked()
})
test('records elementRect', async ({ page }) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck()
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
})
test('animates the cursor and records sub-events by default', async ({
page,
}) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('uncheck')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('scrolls into view before unchecking off-screen checkbox', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await uncheckLocator(page.locator('#offscreen-checkbox-checked')).uncheck()
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually unchecks an off-screen checkbox after scrolling', async ({
page,
}) => {
await uncheckLocator(page.locator('#offscreen-checkbox-checked')).uncheck()
await expect(page.locator('#offscreen-checkbox-checked')).not.toBeChecked()
})
test('with click option: animates cursor and records sub-events', async ({
page,
}) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck({
move: { duration: 100 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('uncheck')
const move = moveEventsIn(event!)[0]!
expect(move).toBeDefined()
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
})
test('with click option: actually unchecks the checkbox', async ({
page,
}) => {
await uncheckLocator(page.locator('#checkbox-checked')).uncheck({
move: { duration: 50 },
})
await expect(page.locator('#checkbox-checked')).not.toBeChecked()
})
})
// ---------------------------------------------------------------------------
// tap
// ---------------------------------------------------------------------------
test.describe('tap instrumentation', () => {
test('records an input event with subType tap', async ({ page }) => {
await tapLocator(page.locator('#tap-target')).tap()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('tap')
})
test('actually triggers the element interaction', async ({ page }) => {
await tapLocator(page.locator('#tap-target')).tap()
// The page's pointerup listener updates #tap-status when tapped
await expect(page.locator('#tap-status')).not.toHaveText('Not yet tapped')
})
test('records elementRect', async ({ page }) => {
await tapLocator(page.locator('#tap-target')).tap()
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
test('animates the cursor and records sub-events by default', async ({
page,
}) => {
await tapLocator(page.locator('#tap-target')).tap()
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('tap')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('scrolls into view before tapping off-screen element', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await tapLocator(page.locator('#offscreen-tap-target')).tap()
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually taps an off-screen element after scrolling', async ({
page,
}) => {
await tapLocator(page.locator('#offscreen-tap-target')).tap()
await expect(page.locator('#offscreen-tap-status')).not.toHaveText(
'Not yet tapped'
)
})
test('with click option: animates cursor and records sub-events', async ({
page,
}) => {
await tapLocator(page.locator('#tap-target')).tap({
move: { duration: 100 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('tap')
// tap doesn't fire a DOM click, so click sub-events use fallback bounding box coords
const move = moveEventsIn(event!)[0]!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(inputElementRect(event!)!.width).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
})
test('with click option: actually triggers the element interaction', async ({
page,
}) => {
await tapLocator(page.locator('#tap-target')).tap({
move: { duration: 50 },
})
await expect(page.locator('#tap-status')).not.toHaveText('Not yet tapped')
})
})
// ---------------------------------------------------------------------------
// selectOption
// ---------------------------------------------------------------------------
test.describe('selectOption instrumentation', () => {
test('records an input event with subType select', async ({ page }) => {
await selectableLocator(page.locator('#cars')).selectOption('audi')
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('select')
})
test('actually selects the option', async ({ page }) => {
await selectableLocator(page.locator('#cars')).selectOption('audi')
await expect(page.locator('#cars')).toHaveValue('audi')
})
test('records elementRect', async ({ page }) => {
await selectableLocator(page.locator('#cars')).selectOption('saab')
const events = inputEvents()
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
test('animates the cursor and records sub-events by default', async ({
page,
}) => {
await selectableLocator(page.locator('#cars')).selectOption('audi')
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(inputElementRect(event!)!.width).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('scrolls into view before selecting off-screen option', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await selectableLocator(page.locator('#offscreen-cars')).selectOption(
'opel'
)
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('actually selects an off-screen option after scrolling', async ({
page,
}) => {
await selectableLocator(page.locator('#offscreen-cars')).selectOption(
'volvo'
)
await expect(page.locator('#offscreen-cars')).toHaveValue('volvo')
})
test('with click option: animates cursor and records sub-events', async ({
page,
}) => {
await selectableLocator(page.locator('#cars')).selectOption('audi', {
move: { duration: 100 },
})
const events = inputEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(event!.subType).toBe('select')
const move = moveEventsIn(event!)[0]!
const down = event!.events.find(
(e): e is MouseDownEvent => e.type === 'mouseDown'
)!
const up = event!.events.find(
(e): e is MouseUpEvent => e.type === 'mouseUp'
)!
expect(move).toBeDefined()
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(inputElementRect(event!)!.width).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(up.endMs).toBeGreaterThanOrEqual(down.startMs)
})
test('with click option: actually selects the option', async ({ page }) => {
await selectableLocator(page.locator('#cars')).selectOption('saab', {
move: { duration: 50 },
})
await expect(page.locator('#cars')).toHaveValue('saab')
})
test('with click option: cursor y is at select center', async ({ page }) => {
await selectableLocator(page.locator('#cars')).selectOption('audi', {
move: { duration: 100 },
})
const events = inputEvents()
const [event] = events
const move = moveEventsIn(event!)[0]!
expect(move.y).toBeGreaterThan(0)
})
})
// ---------------------------------------------------------------------------
// mouse.move
// ---------------------------------------------------------------------------
test.describe('scroll', () => {
test('scrolls an off-screen element into the viewport', async ({ page }) => {
expect(await scrollY(page)).toBe(0)
await changeFocus(page.locator('#offscreen-click-button'))
const box = await page.locator('#offscreen-click-button').boundingBox()
expect(box).not.toBeNull()
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeLessThan(page.viewportSize()!.height)
expect(await scrollY(page)).toBeGreaterThan(0)
})
test('scrolls nested scroll containers without scrollIntoView', async ({
page,
}) => {
expect(await scrollY(page)).toBe(0)
await changeFocus(page.locator('#nested-scroll-target'))
const box = await page.locator('#nested-scroll-target').boundingBox()
expect(box).not.toBeNull()
expect(box!.y).toBeGreaterThan(0)
expect(box!.y).toBeLessThan(1000)
expect(await scrollY(page)).toBeGreaterThan(0)
})
})
test.describe('mouse.move instrumentation', () => {
test('records a mouseMove event with startMs, endMs, x, y', async ({
page,
}) => {
const bb = await page.locator('#click-button').boundingBox()
const targetX = bb!.x + bb!.width / 2
const targetY = bb!.y + bb!.height / 2
await (page.mouse as unknown as InstrumentedMouse).move(targetX, targetY, {
duration: 100,
})
const events = mouseMoveEvents()
expect(events).toHaveLength(1)
const [event] = events
expect(moveStartMs(event!)).toBeGreaterThanOrEqual(0)
expect(moveEndMs(event!)).toBeGreaterThanOrEqual(moveStartMs(event!))
expect(moveEndMs(event!) - moveStartMs(event!)).toBeGreaterThanOrEqual(100)
expect(event!.x).toBeCloseTo(targetX, 0)
expect(event!.y).toBeCloseTo(targetY, 0)
})
test('derives duration from speed and stores it on the mouseMove event', async ({
page,
}) => {
await (page.mouse as unknown as InstrumentedMouse).move(300, 400, {
speed: 500,
})
const events = mouseMoveEvents()
expect(events).toHaveLength(1)
const [event] = events
const expectedDuration = (Math.hypot(300, 400) / 500) * 1000
expect(moveEndMs(event!) - moveStartMs(event!)).toBeGreaterThanOrEqual(
expectedDuration
)
})
test('records easing when duration is provided', async ({ page }) => {
await (page.mouse as unknown as InstrumentedMouse).move(200, 300, {
duration: 100,
easing: 'ease-out',
})
const events = mouseMoveEvents()
expect(events).toHaveLength(1)
expect(
events[0]!.type === 'mouseMove'
? events[0]!.easing
: events[0]!.mouse?.easing
).toBe('ease-out')
})
test('a bare move animates by default with default easing', async ({
page,
}) => {
await (page.mouse as unknown as InstrumentedMouse).move(100, 150)
const events = mouseMoveEvents()
expect(events).toHaveLength(1)
expect(events[0]!.x).toBe(100)
expect(events[0]!.y).toBe(150)
expect(moveEndMs(events[0]!) - moveStartMs(events[0]!)).toBeGreaterThan(0)
expect(
events[0]!.type === 'mouseMove'
? events[0]!.easing
: events[0]!.mouse?.easing
).toBe('ease-in-out')
})
test('records mouseMove without easing for an explicit instant move', async ({
page,
}) => {
await (page.mouse as unknown as InstrumentedMouse).move(100, 150, {
duration: 0,
})
const events = mouseMoveEvents()
expect(events).toHaveLength(1)
expect(events[0]!.x).toBe(100)
expect(events[0]!.y).toBe(150)
expect(
events[0]!.type === 'mouseMove'
? events[0]!.easing
: events[0]!.mouse?.easing
).toBeUndefined()
})
test('cursor ends at target position so subsequent click animates from there', async ({
page,
}) => {
// Move cursor to the button, then click it — the click's moveStartTime
// should be close to moveEndTime (short travel) since cursor is already there.
const bb = await page.locator('#click-button').boundingBox()
const targetX = bb!.x + bb!.width / 2
const targetY = bb!.y + bb!.height / 2
await (page.mouse as unknown as InstrumentedMouse).move(targetX, targetY, {
duration: 100,
})
await clickableLocator(page.locator('#click-button')).click({
move: { duration: 50 },
})
const [click] = clickEvents()
const move = moveEventsIn(click!)[0]!
// The move should be very short since cursor was already at the target
expect(moveEndMs(move) - moveStartMs(move)).toBeLessThan(100)
})
})
// ---------------------------------------------------------------------------
// page.mouse press methods (down / up / click / dblclick)
// ---------------------------------------------------------------------------
test.describe('mouse press instrumentation', () => {
async function clickButtonCenter(page: Page) {
const bb = await page.locator('#click-button').boundingBox()
return { x: bb!.x + bb!.width / 2, y: bb!.y + bb!.height / 2 }
}
test('records page.mouse.down and page.mouse.up as their own input events', async ({
page,
}) => {
const mouse = page.mouse as unknown as InstrumentedMouse
await mouse.down()
await mouse.up()
const subTypes = inputEvents().map((e) => e.subType)
expect(subTypes).toEqual(['mouseDown', 'mouseUp'])
})
test('page.mouse.click actually clicks at the coordinates', async ({
page,
}) => {
const { x, y } = await clickButtonCenter(page)
await (page.mouse as unknown as InstrumentedMouse).click(x, y, {
move: { duration: 50 },
})
await expect(page.locator('#click-status')).not.toHaveText('Not clicked')
expect(clickEvents()).toHaveLength(1)
})
test('fake page.mouse.click records a click but does not click the page', async ({
page,
}) => {
const { x, y } = await clickButtonCenter(page)
await (page.mouse as unknown as InstrumentedMouse).click(x, y, {
move: { duration: 50 },
fake: true,
})
// The page was never actually clicked...
await expect(page.locator('#click-status')).toHaveText('Not clicked')
// ...but the click is still recorded for the video.
const events = clickEvents()
expect(events).toHaveLength(1)
expect(events[0]!.events.some((e) => e.type === 'mouseDown')).toBe(true)
expect(events[0]!.events.some((e) => e.type === 'mouseUp')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// hover
// ---------------------------------------------------------------------------
function hoverableLocator(locator: Locator): {
hover(
opts?: Parameters<Locator['hover']>[0] & {
move?: { duration?: number; easing?: string; delayAfter?: number }
duration?: number
}
): Promise<void>
} {
return locator as never
}
test.describe('hover instrumentation', () => {
test('records a hover event with mouseMove and mouseWait inner events', async ({
page,
}) => {
await hoverableLocator(page.locator('#hover-target')).hover({
move: { duration: 50 },
duration: 100,
})
const events = inputEvents().filter((e) => e.subType === 'hover')
expect(events).toHaveLength(1)
const [event] = events
const move = moveEventsIn(event!)[0]!
const wait = event!.events.find(
(e): e is MouseWaitEvent => e.type === 'mouseWait'
)!
expect(move.x).toBeGreaterThan(0)
expect(move.y).toBeGreaterThan(0)
expect(moveEndMs(move)).toBeGreaterThanOrEqual(moveStartMs(move))
expect(wait.endMs).toBeGreaterThanOrEqual(wait.startMs)
})
test('actually hovers the element', async ({ page }) => {
await hoverableLocator(page.locator('#hover-target')).hover({
move: { duration: 50 },
})
await expect(page.locator('#hover-status')).toHaveText('Hovered!')
})
test('records elementRect on hover', async ({ page }) => {
await hoverableLocator(page.locator('#hover-target')).hover({
move: { duration: 50 },
})
const events = inputEvents().filter((e) => e.subType === 'hover')
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
})
// ---------------------------------------------------------------------------
// selectText
// ---------------------------------------------------------------------------
function selectTextLocator(locator: Locator): {
selectText(
opts?: Parameters<Locator['selectText']>[0] & {
move?: { duration?: number; easing?: string; delayAfter?: number }
duration?: number
}
): Promise<void>
} {
return locator as never
}
test.describe('selectText instrumentation', () => {
test('records a selectText event with mouseMove and 3 down+up pairs', async ({
page,
}) => {
await selectTextLocator(page.locator('#select-text-input')).selectText({
move: { duration: 50 },
duration: 60,
})
const events = inputEvents().filter((e) => e.subType === 'selectText')
expect(events).toHaveLength(1)
const [event] = events
expect(moveEventsIn(event!)).not.toHaveLength(0)
expect(event!.events.filter((e) => e.type === 'mouseDown')).toHaveLength(3)
expect(event!.events.filter((e) => e.type === 'mouseUp')).toHaveLength(3)
})
test('actually selects the text', async ({ page }) => {
await selectTextLocator(page.locator('#select-text-input')).selectText({
move: { duration: 50 },
duration: 60,
})
await expect(page.locator('#select-text-status')).toHaveText('Selected!')
})
test('records elementRect on selectText', async ({ page }) => {
await selectTextLocator(page.locator('#select-text-input')).selectText({
move: { duration: 50 },
duration: 60,
})
const events = inputEvents().filter((e) => e.subType === 'selectText')
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
})
// ---------------------------------------------------------------------------
// dragTo
// ---------------------------------------------------------------------------
function draggableLocator(locator: Locator): {
dragTo(
target: Locator,
opts?: {
move?: { duration?: number; easing?: string; delayAfter?: number }
duration?: number
easing?: string
sourcePosition?: { x: number; y: number }
targetPosition?: { x: number; y: number }
force?: boolean
timeout?: number
trial?: boolean
}
): Promise<void>
} {
return locator as never
}
test.describe('dragTo instrumentation', () => {
test('records a dragTo event with 2 mouseMoves, mouseDown and mouseUp', async ({
page,
}) => {
await draggableLocator(page.locator('#drag-source')).dragTo(
page.locator('#drop-target'),
{ move: { duration: 50 }, duration: 50 }
)
const events = inputEvents().filter((e) => e.subType === 'dragTo')
expect(events).toHaveLength(1)
const [event] = events
const moves = moveEventsIn(event!)
expect(moves).toHaveLength(2)
expect(event!.events.some((e) => e.type === 'mouseDown')).toBe(true)
expect(event!.events.some((e) => e.type === 'mouseUp')).toBe(true)
})
test('actually drags the element', async ({ page }) => {
await draggableLocator(page.locator('#drag-source')).dragTo(
page.locator('#drop-target'),
{ move: { duration: 50 }, duration: 50 }
)
await expect(page.locator('#drag-status')).toHaveText('Dropped!')
})
test('records elementRect on dragTo', async ({ page }) => {
await draggableLocator(page.locator('#drag-source')).dragTo(
page.locator('#drop-target'),
{ move: { duration: 50 }, duration: 50 }
)
const events = inputEvents().filter((e) => e.subType === 'dragTo')
const [event] = events
const rect = inputElementRect(event!)
expect(rect).toBeDefined()
expect(rect!.width).toBeGreaterThan(0)
expect(rect!.height).toBeGreaterThan(0)
})
})