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.'
);
});