diff --git a/src/PuppeteerRunnerExtension.ts b/src/PuppeteerRunnerExtension.ts index 7fc08d4f..a1524813 100644 --- a/src/PuppeteerRunnerExtension.ts +++ b/src/PuppeteerRunnerExtension.ts @@ -154,6 +154,24 @@ export class PuppeteerRunnerExtension extends RunnerExtension { }, }); break; + case StepType.Drag: { + let [x, y] = [step.offsetX, step.offsetY]; + await mainPage.mouse.move(x, y); + await mainPage.mouse.down({ + button: step.button && mouseButtonMap.get(step.button), + }); + for (let i = 0; i < step.deltas.length; i += 2) { + [x, y] = [ + x + (step.deltas[i] as number), + y + (step.deltas[i + 1] as number), + ]; + await mainPage.mouse.move(x, y); + } + await mainPage.mouse.up({ + button: step.button && mouseButtonMap.get(step.button), + }); + break; + } case StepType.Hover: await locatorRace( step.selectors.map((selector) => { diff --git a/src/PuppeteerStringifyExtension.ts b/src/PuppeteerStringifyExtension.ts index a97f4ef1..02547e37 100644 --- a/src/PuppeteerStringifyExtension.ts +++ b/src/PuppeteerStringifyExtension.ts @@ -24,6 +24,7 @@ import type { HoverStep, KeyDownStep, KeyUpStep, + DragStep, NavigateStep, ScrollStep, SetViewportStep, @@ -302,6 +303,8 @@ export class PuppeteerStringifyExtension extends StringifyExtension { return this.#appendScrollStep(out, step); case StepType.Navigate: return this.#appendNavigationStep(out, step); + case StepType.Drag: + return this.#appendDragStep(out, step); case StepType.WaitForElement: return this.#appendWaitForElementStep(out, step); case StepType.WaitForExpression: @@ -322,6 +325,21 @@ export class PuppeteerStringifyExtension extends StringifyExtension { ); } + #appendDragStep(out: LineWriter, step: DragStep): void { + out.appendLine(` +{ + const deltas = new Int8Array("${step.deltas.toString()}".split(",")); + + let [x, y] = [${step.offsetX}, ${step.offsetY}]; + await targetPage.mouse.move(x, y); + for (let i = 0; i < deltas.length; i += 2) { + [x, y] = [x + deltas[i], y + deltas[i + 1]]; + await targetPage.mouse.move(x, y); + } +} +`); + } + #appendWaitExpressionStep( out: LineWriter, step: WaitForExpressionStep diff --git a/src/Schema.ts b/src/Schema.ts index baf51a18..9fc29e2c 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -40,6 +40,7 @@ export enum StepType { KeyDown = 'keyDown', KeyUp = 'keyUp', Navigate = 'navigate', + Drag = 'move', Scroll = 'scroll', SetViewport = 'setViewport', WaitForElement = 'waitForElement', @@ -110,11 +111,13 @@ export type PointerButtonType = export interface ClickAttributes { /** - * Pointer type for the event. Defaults to 'mouse'. + * Pointer type for the event. + * + * @defaultValue `'mouse'` */ deviceType?: PointerDeviceType; /** - * Defaults to 'primary' if the device type is a mouse. + * @defaultValue `'primary'` if the {@link deviceType} is `'mouse'`. */ button?: PointerButtonType; /** @@ -141,6 +144,22 @@ export interface DoubleClickStep extends ClickAttributes, StepWithSelectors { export interface ClickStep extends ClickAttributes, StepWithSelectors { type: StepType.Click; + /** + * Delay (in ms) between the mouse up and mouse down of the click. + * + * @defaultValue `50` + */ + duration?: number; +} + +export interface DragStep extends ClickAttributes, StepWithTarget { + type: StepType.Drag; + /** + * An flattened array of (offsetX, offsetY)-deltas to defining the movement. + * + * @defaultValue `[]` + */ + deltas: Int8Array; } export interface HoverStep extends StepWithSelectors { @@ -224,6 +243,7 @@ export type UserStep = | EmulateNetworkConditionsStep | KeyDownStep | KeyUpStep + | DragStep | NavigateStep | ScrollStep | SetViewportStep; diff --git a/src/SchemaUtils.ts b/src/SchemaUtils.ts index 83f97adb..f8debf93 100644 --- a/src/SchemaUtils.ts +++ b/src/SchemaUtils.ts @@ -29,6 +29,7 @@ import { Key, KeyDownStep, KeyUpStep, + DragStep, NavigateStep, ScrollStep, Selector, @@ -154,6 +155,21 @@ function parseNumber(step: object, prop: string): number { throw new Error(`Step.${prop} is not a number`); } +function parseInt8Array(step: object, prop: string): Int8Array { + if (hasProperty(step, prop)) { + const value = step[prop]; + if (value instanceof Int8Array) { + return value; + } else if (isIntegerArray(value)) { + return new Int8Array(value); + } else if (isString(value)) { + // The Int8Array constructor automatically does the number conversion. + return new Int8Array(value.split(',') as unknown as number[]); + } + } + throw new Error(`Step.${prop} is not an typed byte array`); +} + function parseBoolean(step: object, prop: string): boolean { if (hasProperty(step, prop)) { const maybeBoolean = step[prop]; @@ -427,6 +443,16 @@ function parseNavigateStep(step: object): NavigateStep { }; } +function parseDragStep(step: object): DragStep { + return { + ...parseStepWithTarget(StepType.Drag, step), + type: StepType.Drag, + offsetX: parseNumber(step, 'offsetX'), + offsetY: parseNumber(step, 'offsetY'), + deltas: parseInt8Array(step, 'deltas'), + }; +} + function parseWaitForElementStep(step: object): WaitForElementStep { const operator = parseOptionalString(step, 'operator'); if (operator && operator !== '>=' && operator !== '==' && operator !== '<=') { @@ -533,6 +559,8 @@ export function parseStep(step: unknown, idx?: number): Step { return parseScrollStep(step); case StepType.Navigate: return parseNavigateStep(step); + case StepType.Drag: + return parseDragStep(step); case StepType.CustomStep: return parseCustomStep(step); case StepType.WaitForElement: diff --git a/test/resources/drawable-canvas.html b/test/resources/drawable-canvas.html new file mode 100644 index 00000000..ba20baa9 --- /dev/null +++ b/test/resources/drawable-canvas.html @@ -0,0 +1,22 @@ + + diff --git a/test/runner.test.ts b/test/runner.test.ts index a336d64a..c2f60034 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -1009,6 +1009,40 @@ describe('Runner', () => { assert.ok(frame, 'Frame that the target page navigated to is not found'); }); + it.only('should replay movements', async () => { + const runner = await createRunner( + { + title: 'Test Recording', + timeout: 3000, + steps: [ + { + type: StepType.Navigate, + url: `${HTTP_PREFIX}/drawable-canvas.html`, + }, + { + type: StepType.Drag, + offsetX: 46, + offsetY: 41, + deltas: new Int8Array([ + 0, 0, 0, 1, 0, 2, 1, 2, 2, 3, 3, 4, 3, 3, 3, 2, 2, 1, 1, 0, 1, 0, + 2, -1, 2, -2, 2, -1, 1, -1, 1, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, + -1, -1, -2, -2, -3, -4, -4, -4, -4, -3, -3, -2, -1, -2, 0, -1, 0, + -1, 0, -1, 0, -2, 0, -3, 1, -3, 1, -3, 1, -2, 1, -1, 0, 0, 0, 0, + 0, + ]), + }, + ], + }, + new PuppeteerRunnerExtension(browser, page) + ); + await runner.run(); + assert.strictEqual( + await page.evaluate(() => document.querySelector('canvas')?.toDataURL()), + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAspJREFUeF7tmktu1EAURW8gQEZEjGCIyIqSdbIXVgDKEDHhM+ITAipki06npbjxc+eUOZZaainlp/vuqVsuV+coXigHjlBqFBOBwCaBQAQCcwAmx4QIBOYATI4JEQjMAZgcEyIQmAMwOSZEIDAHYHJMiEBgDsDkmBCBwByAyTEhAoE5AJNjQgQCcwAmx4QIZLIDH5M8HUbvmjjfk5xMrtbJQFpC3id5vqd3tB72lH9zOKGZt0lebcj6lOTZxK6+JXmcrOe/Z+4LyOskF4ORv5J8SPJiIoRdw1qN++plhuzbtx66iXdJXg7mte9nhd2sAsqhgVwmeZPkvBDEZqnuoRwayEIcbpTtGsoagTQ63UJZK5BuoawZSJdQ1g6kOyhUINfD1rhKXzfPlKqGq3dP1UC6Scr/BOTzcFhJ7fnPpKaKu0rycAF9belqnwfVka6qRwUyLjFt6WpgKq8G5EuS08qiVbXIQNrvHY8WSMlS6SthQgYypmSJJabVXCJ9s6HQgYxQfgy/e8xueCiwxC6uRFsPQL4meVK8dAlk5vSpNrC63sz2/t7eQ0JGtZVbVoEUTaEGpe2+2hI25xLIHPe27q04l6qoUdhSn0vWpgH/aij6HYR8dDJl9jUo+2yH23h8zz091HdBmmoy9kVwu6negbR+xgf0zyTHWw3il6g1Amk9jede45v95tLU1aTrSuyEB0tLyXi0jjyruquHtQG5q1/83wUCQyQQgcAcgMkxIQKBOQCTY0IEAnMAJseECATmAEyOCREIzAGYHBMiEJgDMDkmRCAwB2ByTIhAYA7A5JgQgcAcgMkxIQKBOQCTY0IEAnMAJseECATmAEyOCREIzAGYHBMiEJgDMDkmRCAwB2ByTIhAYA7A5JgQgcAcgMkxIQKBOQCTY0IEAnMAJseECATmAEyOCREIzAGYHBMiEJgDMDkmRCAwB2ByfgOYo0ZleqjU4gAAAABJRU5ErkJggg==', + 'Drawings did not match.' + ); + }); + it('should replay hovers', async () => { const runner = await createRunner( { @@ -1040,7 +1074,7 @@ describe('Runner', () => { await page.evaluate( () => document.getElementById('hover-button')?.textContent ), - 'Hovered' + 'Hover button was either not found or not hovered.' ); });