@immense/vue-pom-generator 1.0.13 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +20 -20
- package/class-generation/Pointer.ts +222 -27
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
●
|
|
1
|
+
● ```markdown
|
|
2
|
+
# v1.0.14
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
- Added support for dynamic `test-id` input elements in class generation
|
|
5
|
-
- Introduced dedicated path utilities module for improved modularity
|
|
6
|
-
- Enhanced test coverage with new component naming test suite
|
|
7
|
-
- Added automated PR release notes preview comments
|
|
4
|
+
## Highlights
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
- **Animated cursor overlay** for Playwright recordings with enhanced visual feedback
|
|
7
|
+
- Added comprehensive PomElement wrapper design and implementation documentation
|
|
8
|
+
- Enhanced Pointer component with 249 lines of improvements
|
|
9
|
+
- Automated PR release notes preview comments via GitHub Actions
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- Path-based POM naming now works correctly with Nuxt 4
|
|
13
|
-
- Dynamic `test-id` input elements are properly handled in class generation
|
|
11
|
+
## Changes
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
13
|
+
### Features
|
|
14
|
+
- Animated cursor overlay for Playwright recordings to improve test visualization
|
|
15
|
+
- Enhanced `class-generation/Pointer.ts` with significant improvements (249 lines)
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
- Added
|
|
21
|
-
|
|
17
|
+
### Documentation
|
|
18
|
+
- Added PomElement wrapper design document
|
|
19
|
+
(`docs/plans/2026-03-03-pom-element-wrapper-design.md`)
|
|
20
|
+
- Added PomElement wrapper implementation plan
|
|
21
|
+
(`docs/plans/2026-03-03-pom-element-wrapper-implementation.md`)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
-
|
|
23
|
+
### Automation
|
|
24
|
+
- PR release notes preview comments automatically generated on pull requests
|
|
25
25
|
|
|
26
26
|
## Breaking Changes
|
|
27
27
|
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
|
|
35
35
|
## Testing
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
Testing details not provided in release window.
|
|
38
|
+
```
|
|
39
39
|
|
|
@@ -1,39 +1,138 @@
|
|
|
1
1
|
import type { PwLocator, PwPage } from "./playwright-types";
|
|
2
2
|
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Cursor visual overlay helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const __PW_CURSOR_ID__ = "__pw_cursor__";
|
|
8
|
+
|
|
9
|
+
// A minimal 16×24 arrow cursor encoded as a base64 PNG.
|
|
10
|
+
const __PW_CURSOR_PNG__ =
|
|
11
|
+
"data:image/png;base64,"
|
|
12
|
+
+ "iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAQAAACGG/bgAAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAA"
|
|
13
|
+
+ "HsYAAB7GAZEt8iwAAAAHdElNRQfgAwgMIwdxU/i7AAABZklEQVQ4y43TsU4UURSH8W+XmYwkS2I0"
|
|
14
|
+
+ "9CRKpKGhsvIJjG9giQmliHFZlkUIGnEF7KTiCagpsYHWhoTQaiUUxLixYZb5KAAZZhbunu7O/PKf"
|
|
15
|
+
+ "e+fcA+/pqwb4DuximEqXhT4iI8dMpBWEsWsuGYdpZFttiLSSgTvhZ1W/SvfO1CvYdV1kPghV68a3"
|
|
16
|
+
+ "0zzUWZH5pBqEui7dnqlFmLoq0gxC1XfGZdoLal2kea8ahLoqKXNAJQBT2yJzwUTVt0bS6ANqy1ga"
|
|
17
|
+
+ "VCEq/oVTtjji4hQVhhnlYBH4WIJV9vlkXLm+10R8oJb79Jl1j9UdazJRGpkrmNkSF9SOz2T71s7M"
|
|
18
|
+
+ "SIfD2lmmfjGSRz3hK8l4w1P+bah/HJLN0sys2JSMZQB+jKo6KSc8vLlLn5ikzF4268Wg2+pPOWW6"
|
|
19
|
+
+ "ONcpr3PrXy9VfS473M/D7H+TLmrqsXtOGctvxvMv2oVNP+Av0uHbzbxyJaywyUjx8TlnPY2YxqkD"
|
|
20
|
+
+ "dAAAAABJRU5ErkJggg==";
|
|
21
|
+
|
|
22
|
+
// Per-page cursor position (viewport coords). WeakMap so pages can be GC'd.
|
|
23
|
+
const __pw_cursor_positions__ = new WeakMap<object, { x: number; y: number }>();
|
|
24
|
+
|
|
25
|
+
function __pw_get_cursor_pos__(page: PwPage): { x: number; y: number } {
|
|
26
|
+
return __pw_cursor_positions__.get(page as object) ?? { x: 0, y: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function __pw_set_cursor_pos__(page: PwPage, x: number, y: number): void {
|
|
30
|
+
__pw_cursor_positions__.set(page as object, { x, y });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function __pw_ensure_cursor__(page: PwPage): Promise<void> {
|
|
34
|
+
const exists = await page.evaluate(
|
|
35
|
+
(id: string) => document.getElementById(id) != null,
|
|
36
|
+
__PW_CURSOR_ID__,
|
|
37
|
+
);
|
|
38
|
+
if (exists) return;
|
|
39
|
+
|
|
40
|
+
// Reset tracked position for this page.
|
|
41
|
+
__pw_set_cursor_pos__(page, 0, 0);
|
|
42
|
+
|
|
43
|
+
await page.evaluate(
|
|
44
|
+
({ id, src }: { id: string; src: string }) => {
|
|
45
|
+
const img = document.createElement("img");
|
|
46
|
+
img.setAttribute("src", src);
|
|
47
|
+
img.setAttribute("id", id);
|
|
48
|
+
// position:fixed keeps coordinates viewport-relative (matching Playwright boundingBox).
|
|
49
|
+
img.setAttribute(
|
|
50
|
+
"style",
|
|
51
|
+
"position:fixed;z-index:2147483647;pointer-events:none;left:0;top:0;transform-origin:0 0;",
|
|
52
|
+
);
|
|
53
|
+
document.body.appendChild(img);
|
|
54
|
+
},
|
|
55
|
+
{ id: __PW_CURSOR_ID__, src: __PW_CURSOR_PNG__ },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Animation options
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
3
63
|
export interface PlaywrightAnimationOptions {
|
|
4
64
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
65
|
+
* Set to false to disable all animations and delays. Clicks/fills still happen.
|
|
7
66
|
* Default: true
|
|
8
67
|
*/
|
|
9
68
|
enabled?: boolean;
|
|
10
69
|
|
|
11
70
|
/**
|
|
12
|
-
* Extra delay
|
|
13
|
-
*
|
|
71
|
+
* Extra delay (ms) added before every action on top of per-action delays.
|
|
14
72
|
* Default: 0
|
|
15
73
|
*/
|
|
16
74
|
extraDelayMs?: number;
|
|
75
|
+
|
|
76
|
+
/** Visual cursor / pointer-movement configuration. */
|
|
77
|
+
pointer?: {
|
|
78
|
+
/**
|
|
79
|
+
* Duration of the CSS-transition cursor glide in ms.
|
|
80
|
+
* Set to 0 to teleport the cursor without animation.
|
|
81
|
+
* Default: 250
|
|
82
|
+
*/
|
|
83
|
+
durationMilliseconds?: number;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* CSS transition timing function for the cursor glide.
|
|
87
|
+
* Default: "ease-in-out"
|
|
88
|
+
*/
|
|
89
|
+
transitionStyle?: "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delay (ms) passed to element.click({ delay }) for a realistic press.
|
|
93
|
+
* Default: 0
|
|
94
|
+
*/
|
|
95
|
+
clickDelayMilliseconds?: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Keyboard / typing configuration. */
|
|
99
|
+
keyboard?: {
|
|
100
|
+
/**
|
|
101
|
+
* Delay between keystrokes in ms – makes typing visible on screen / in video.
|
|
102
|
+
* Default: 100
|
|
103
|
+
*/
|
|
104
|
+
typeDelayMilliseconds?: number;
|
|
105
|
+
};
|
|
17
106
|
}
|
|
18
107
|
|
|
19
|
-
let animationOptions: PlaywrightAnimationOptions = {
|
|
108
|
+
let animationOptions: PlaywrightAnimationOptions = {
|
|
109
|
+
enabled: true,
|
|
110
|
+
extraDelayMs: 0,
|
|
111
|
+
pointer: { durationMilliseconds: 250, transitionStyle: "ease-in-out", clickDelayMilliseconds: 0 },
|
|
112
|
+
keyboard: { typeDelayMilliseconds: 100 },
|
|
113
|
+
};
|
|
20
114
|
|
|
21
115
|
export function setPlaywrightAnimationOptions(options: PlaywrightAnimationOptions): void {
|
|
22
116
|
animationOptions = {
|
|
23
117
|
enabled: options?.enabled ?? true,
|
|
24
118
|
extraDelayMs: options?.extraDelayMs ?? 0,
|
|
119
|
+
pointer: {
|
|
120
|
+
durationMilliseconds: options?.pointer?.durationMilliseconds ?? 250,
|
|
121
|
+
transitionStyle: options?.pointer?.transitionStyle ?? "ease-in-out",
|
|
122
|
+
clickDelayMilliseconds: options?.pointer?.clickDelayMilliseconds ?? 0,
|
|
123
|
+
},
|
|
124
|
+
keyboard: {
|
|
125
|
+
typeDelayMilliseconds: options?.keyboard?.typeDelayMilliseconds ?? 100,
|
|
126
|
+
},
|
|
25
127
|
};
|
|
26
128
|
}
|
|
27
129
|
|
|
28
130
|
export interface AfterPointerClickInfo {
|
|
29
|
-
/**
|
|
30
|
-
* Resolved test id from the clicked element (if present).
|
|
31
|
-
*/
|
|
131
|
+
/** Resolved test id from the clicked element (if present). */
|
|
32
132
|
testId?: string;
|
|
33
133
|
|
|
34
134
|
/**
|
|
35
|
-
* Whether the click should be considered
|
|
36
|
-
*
|
|
135
|
+
* Whether the click should be considered "instrumented".
|
|
37
136
|
* BasePage uses this flag to decide whether to wait for the injected click event.
|
|
38
137
|
*/
|
|
39
138
|
instrumented: boolean;
|
|
@@ -43,6 +142,10 @@ export type AfterPointerClick = (info: AfterPointerClickInfo) => void | Promise<
|
|
|
43
142
|
|
|
44
143
|
type ElementTarget = string | PwLocator;
|
|
45
144
|
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Pointer class
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
46
149
|
export class Pointer {
|
|
47
150
|
private readonly page: PwPage;
|
|
48
151
|
private readonly testIdAttribute: string;
|
|
@@ -73,35 +176,118 @@ export class Pointer {
|
|
|
73
176
|
): Promise<void> {
|
|
74
177
|
const locator = this.toLocator(target);
|
|
75
178
|
|
|
76
|
-
// Best-effort “animation”: make sure the element is scrolled into view and add a delay.
|
|
77
179
|
try {
|
|
78
180
|
await locator.first().scrollIntoViewIfNeeded();
|
|
79
181
|
}
|
|
80
182
|
catch {
|
|
81
|
-
//
|
|
183
|
+
// Element may detach during navigation; let the subsequent action surface the error.
|
|
82
184
|
}
|
|
83
185
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
186
|
+
const opts = animationOptions;
|
|
187
|
+
const animEnabled = opts.enabled !== false;
|
|
188
|
+
|
|
189
|
+
if (!animEnabled) {
|
|
190
|
+
// Fast path: no animations.
|
|
191
|
+
const extraDelay = Math.max(0, opts.extraDelayMs ?? 0);
|
|
192
|
+
if (extraDelay > 0) await this.page.waitForTimeout(extraDelay);
|
|
193
|
+
|
|
194
|
+
let clickedTestId: string | undefined;
|
|
195
|
+
if (executeClick) {
|
|
196
|
+
try { clickedTestId = await this.getTestId(locator); } catch { /* noop */ }
|
|
197
|
+
await locator.first().click({ force: true });
|
|
198
|
+
}
|
|
199
|
+
if (options?.afterClick) {
|
|
200
|
+
await options.afterClick({ testId: clickedTestId, instrumented: Boolean(clickedTestId) });
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
87
203
|
}
|
|
88
204
|
|
|
205
|
+
// --- Animated path ---
|
|
206
|
+
const moveDurationMs = opts.pointer?.durationMilliseconds ?? 250;
|
|
207
|
+
const transitionStyle = opts.pointer?.transitionStyle ?? "ease-in-out";
|
|
208
|
+
const clickDelayMs = opts.pointer?.clickDelayMilliseconds ?? 0;
|
|
209
|
+
const extraDelayMs = Math.max(0, opts.extraDelayMs ?? 0);
|
|
210
|
+
const actionDelayMs = Math.max(0, delayMs);
|
|
211
|
+
|
|
212
|
+
// Inject the visual cursor if it doesn't exist yet.
|
|
213
|
+
await __pw_ensure_cursor__(this.page);
|
|
214
|
+
|
|
215
|
+
// Move the cursor to the target element.
|
|
216
|
+
const box = await locator.first().boundingBox();
|
|
217
|
+
if (box) {
|
|
218
|
+
const endX = box.x + box.width / 2;
|
|
219
|
+
const endY = box.y + box.height / 2;
|
|
220
|
+
const { x: startX, y: startY } = __pw_get_cursor_pos__(this.page);
|
|
221
|
+
const distance = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
|
222
|
+
|
|
223
|
+
if (moveDurationMs > 0 && distance > 0) {
|
|
224
|
+
// Glide the cursor image using a CSS transition.
|
|
225
|
+
await this.page.evaluate(
|
|
226
|
+
({ id, sx, sy, ex, ey, dur, style }: {
|
|
227
|
+
id: string; sx: number; sy: number; ex: number; ey: number; dur: number; style: string;
|
|
228
|
+
}) => {
|
|
229
|
+
const el = document.getElementById(id);
|
|
230
|
+
if (!el) return;
|
|
231
|
+
el.style.transition = "";
|
|
232
|
+
el.style.willChange = "left, top";
|
|
233
|
+
el.style.left = `${sx}px`;
|
|
234
|
+
el.style.top = `${sy}px`;
|
|
235
|
+
// Force reflow so the browser registers the start position before transitioning.
|
|
236
|
+
void el.offsetWidth;
|
|
237
|
+
el.style.transition = `left ${dur}ms ${style}, top ${dur}ms ${style}`;
|
|
238
|
+
el.style.left = `${ex}px`;
|
|
239
|
+
el.style.top = `${ey}px`;
|
|
240
|
+
},
|
|
241
|
+
{ id: __PW_CURSOR_ID__, sx: startX, sy: startY, ex: endX, ey: endY, dur: moveDurationMs, style: transitionStyle },
|
|
242
|
+
);
|
|
243
|
+
// Wait for the animation to finish.
|
|
244
|
+
await this.page.waitForTimeout(moveDurationMs + 25);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// Teleport (distance 0 or duration 0).
|
|
248
|
+
await this.page.evaluate(
|
|
249
|
+
({ id, x, y }: { id: string; x: number; y: number }) => {
|
|
250
|
+
const el = document.getElementById(id);
|
|
251
|
+
if (el) { el.style.left = `${x}px`; el.style.top = `${y}px`; }
|
|
252
|
+
},
|
|
253
|
+
{ id: __PW_CURSOR_ID__, x: endX, y: endY },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
__pw_set_cursor_pos__(this.page, endX, endY);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Apply action delay + extra delay.
|
|
261
|
+
const totalDelay = actionDelayMs + extraDelayMs;
|
|
262
|
+
if (totalDelay > 0) await this.page.waitForTimeout(totalDelay);
|
|
263
|
+
|
|
89
264
|
let clickedTestId: string | undefined;
|
|
90
265
|
if (executeClick) {
|
|
91
|
-
|
|
92
|
-
|
|
266
|
+
// Brief scale-down "press" animation on the cursor image.
|
|
267
|
+
if (moveDurationMs > 0) {
|
|
268
|
+
const pressDur = Math.max(80, Math.round(moveDurationMs / 3));
|
|
269
|
+
await this.page.evaluate(
|
|
270
|
+
({ id, dur }: { id: string; dur: number }) => {
|
|
271
|
+
const el = document.getElementById(id);
|
|
272
|
+
if (el) {
|
|
273
|
+
el.style.transition = `transform ${dur}ms`;
|
|
274
|
+
el.style.transform = "scale(0.6)";
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
el.style.transition = `transform ${dur}ms`;
|
|
277
|
+
el.style.transform = "scale(1)";
|
|
278
|
+
}, dur);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{ id: __PW_CURSOR_ID__, dur: pressDur },
|
|
282
|
+
);
|
|
93
283
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
await locator.first().click({ force: true });
|
|
284
|
+
|
|
285
|
+
try { clickedTestId = await this.getTestId(locator); } catch { /* noop */ }
|
|
286
|
+
await locator.first().click({ delay: clickDelayMs, force: true });
|
|
98
287
|
}
|
|
99
288
|
|
|
100
289
|
if (options?.afterClick) {
|
|
101
|
-
await options.afterClick({
|
|
102
|
-
testId: clickedTestId,
|
|
103
|
-
instrumented: Boolean(clickedTestId),
|
|
104
|
-
});
|
|
290
|
+
await options.afterClick({ testId: clickedTestId, instrumented: Boolean(clickedTestId) });
|
|
105
291
|
}
|
|
106
292
|
}
|
|
107
293
|
|
|
@@ -115,10 +301,19 @@ export class Pointer {
|
|
|
115
301
|
afterClick?: AfterPointerClick;
|
|
116
302
|
},
|
|
117
303
|
): Promise<void> {
|
|
118
|
-
//
|
|
304
|
+
// Animate cursor + click first.
|
|
119
305
|
await this.animateCursorToElement(target, executeClick, delayMs, annotationText, options);
|
|
120
306
|
|
|
121
307
|
const locator = this.toLocator(target);
|
|
122
|
-
|
|
308
|
+
const typeDelayMs = animationOptions.keyboard?.typeDelayMilliseconds ?? 100;
|
|
309
|
+
|
|
310
|
+
if (animationOptions.enabled !== false && typeDelayMs > 0) {
|
|
311
|
+
// Clear existing content, then type character-by-character so keystrokes are visible.
|
|
312
|
+
await locator.first().clear();
|
|
313
|
+
await this.page.keyboard.type(text, { delay: typeDelayMs });
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
await locator.first().fill(text);
|
|
317
|
+
}
|
|
123
318
|
}
|
|
124
319
|
}
|
package/package.json
CHANGED