@immense/vue-pom-generator 1.0.13 → 1.0.15
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 +17 -22
- package/class-generation/Pointer.ts +222 -27
- package/dist/eslint/index.cjs +68 -0
- package/dist/eslint/index.cjs.map +1 -0
- package/dist/eslint/index.mjs +68 -0
- package/dist/eslint/index.mjs.map +1 -0
- package/package.json +12 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
● ## Highlights
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
3
|
+
- New ESLint rule: `no-raw-locator-action` enforces best practices for Vue component locator
|
|
4
|
+
usage in Page Object Models
|
|
5
|
+
- ESLint plugin now available as a sub-export via `./eslint`
|
|
6
|
+
- Added comprehensive test coverage with 73 test cases for the new ESLint rule
|
|
7
|
+
- PR release notes preview comments feature
|
|
8
8
|
|
|
9
9
|
## Changes
|
|
10
10
|
|
|
11
|
-
**
|
|
12
|
-
-
|
|
13
|
-
|
|
11
|
+
**ESLint Plugin**
|
|
12
|
+
- Added `no-raw-locator-action` rule to prevent direct locator method calls without proper Page
|
|
13
|
+
Object Model abstraction
|
|
14
|
+
- Exported ESLint configuration via `./eslint` sub-export in package.json
|
|
14
15
|
|
|
15
|
-
**
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
**Testing**
|
|
20
|
-
- Added `tests/component-naming.test.ts` with 93 new lines of test coverage
|
|
21
|
-
- Enhanced `tests/class-generation-coverage.test.ts` with additional cases
|
|
22
|
-
|
|
23
|
-
**CI/Workflow**
|
|
24
|
-
- Automated release notes preview comments on pull requests
|
|
16
|
+
**Testing & Infrastructure**
|
|
17
|
+
- Added `tests/eslint.test.ts` with comprehensive test suite
|
|
18
|
+
- Updated Vite configuration to support ESLint plugin distribution
|
|
19
|
+
- Updated knip.json dependency configuration
|
|
25
20
|
|
|
26
21
|
## Breaking Changes
|
|
27
22
|
|
|
@@ -29,11 +24,11 @@
|
|
|
29
24
|
|
|
30
25
|
## Pull Requests Included
|
|
31
26
|
|
|
32
|
-
- #1 Add PR release-notes preview
|
|
33
|
-
|
|
27
|
+
- #1 [Add PR release-notes preview
|
|
28
|
+
comments](https://github.com/immense/vue-pom-generator/pull/1) by @dkattan
|
|
34
29
|
|
|
35
30
|
## Testing
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
Added comprehensive test suite with 73 test cases covering valid and invalid usage patterns for
|
|
33
|
+
the new ESLint rule.
|
|
39
34
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const LOCATOR_ACTIONS = /* @__PURE__ */ new Set([
|
|
4
|
+
"click",
|
|
5
|
+
"dblclick",
|
|
6
|
+
"fill",
|
|
7
|
+
"check",
|
|
8
|
+
"uncheck",
|
|
9
|
+
"type",
|
|
10
|
+
"clear",
|
|
11
|
+
"selectOption",
|
|
12
|
+
"setInputFiles",
|
|
13
|
+
"tap",
|
|
14
|
+
"hover",
|
|
15
|
+
"focus",
|
|
16
|
+
"dispatchEvent",
|
|
17
|
+
"press",
|
|
18
|
+
"selectText"
|
|
19
|
+
]);
|
|
20
|
+
const CHAIN_METHODS = /* @__PURE__ */ new Set(["last", "first", "nth", "filter"]);
|
|
21
|
+
function getPomGetterName(node) {
|
|
22
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
|
|
23
|
+
const name = node.property.name;
|
|
24
|
+
if (/^[A-Z]/.test(name)) return name;
|
|
25
|
+
}
|
|
26
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier" && CHAIN_METHODS.has(node.callee.property.name)) {
|
|
27
|
+
return getPomGetterName(node.callee.object);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const noRawLocatorActionRule = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "suggestion",
|
|
34
|
+
docs: {
|
|
35
|
+
description: "Disallow calling raw Playwright action methods directly on POM element getters. Use the generated typed POM methods instead (e.g. `clickSubmitButton()`)."
|
|
36
|
+
},
|
|
37
|
+
messages: {
|
|
38
|
+
noRawAction: "Use the generated POM method instead of `{{getter}}.{{method}}()`. Call `click{{getter}}()` / `type{{getter}}(text)` or similar."
|
|
39
|
+
},
|
|
40
|
+
schema: []
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
return {
|
|
44
|
+
CallExpression(node) {
|
|
45
|
+
if (node.callee.type !== "MemberExpression") return;
|
|
46
|
+
const callee = node.callee;
|
|
47
|
+
if (callee.computed || callee.property.type !== "Identifier") return;
|
|
48
|
+
const methodName = callee.property.name;
|
|
49
|
+
if (!LOCATOR_ACTIONS.has(methodName)) return;
|
|
50
|
+
const getterName = getPomGetterName(callee.object);
|
|
51
|
+
if (!getterName) return;
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
messageId: "noRawAction",
|
|
55
|
+
data: { getter: getterName, method: methodName }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const plugin = {
|
|
62
|
+
rules: {
|
|
63
|
+
"no-raw-locator-action": noRawLocatorActionRule
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
exports.noRawLocatorActionRule = noRawLocatorActionRule;
|
|
67
|
+
exports.plugin = plugin;
|
|
68
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../../eslint/index.ts"],"sourcesContent":["import type { Rule } from \"eslint\";\nimport type { CallExpression, Expression, MemberExpression } from \"estree\";\n\n/**\n * Playwright locator action methods that should be called via generated POM\n * methods rather than directly on element getters.\n */\nconst LOCATOR_ACTIONS = new Set([\n\t\"click\",\n\t\"dblclick\",\n\t\"fill\",\n\t\"check\",\n\t\"uncheck\",\n\t\"type\",\n\t\"clear\",\n\t\"selectOption\",\n\t\"setInputFiles\",\n\t\"tap\",\n\t\"hover\",\n\t\"focus\",\n\t\"dispatchEvent\",\n\t\"press\",\n\t\"selectText\",\n]);\n\n/**\n * Locator chain methods that are transparent for the purposes of this rule —\n * `.last().click()` is still a raw action on a POM getter.\n */\nconst CHAIN_METHODS = new Set([\"last\", \"first\", \"nth\", \"filter\"]);\n\n/**\n * Returns the PascalCase getter name if `node` is (or chains from) a direct\n * PascalCase member-expression access. Returns null otherwise.\n *\n * Handles:\n * pom.SubmitButton → \"SubmitButton\"\n * pom.SubmitButton.last() → \"SubmitButton\"\n * pom.SubmitButton.nth(0) → \"SubmitButton\"\n */\nfunction getPomGetterName(node: Expression): string | null {\n\tif (node.type === \"MemberExpression\" && !node.computed && node.property.type === \"Identifier\") {\n\t\tconst name = node.property.name;\n\t\tif (/^[A-Z]/.test(name)) return name;\n\t}\n\n\tif (\n\t\tnode.type === \"CallExpression\"\n\t\t&& node.callee.type === \"MemberExpression\"\n\t\t&& !node.callee.computed\n\t\t&& node.callee.property.type === \"Identifier\"\n\t\t&& CHAIN_METHODS.has(node.callee.property.name)\n\t) {\n\t\treturn getPomGetterName((node.callee as MemberExpression).object as Expression);\n\t}\n\n\treturn null;\n}\n\nexport const noRawLocatorActionRule: Rule.RuleModule = {\n\tmeta: {\n\t\ttype: \"suggestion\",\n\t\tdocs: {\n\t\t\tdescription:\n\t\t\t\t\"Disallow calling raw Playwright action methods directly on POM element getters. Use the generated typed POM methods instead (e.g. `clickSubmitButton()`).\",\n\t\t},\n\t\tmessages: {\n\t\t\tnoRawAction:\n\t\t\t\t\"Use the generated POM method instead of `{{getter}}.{{method}}()`. \"\n\t\t\t\t+ \"Call `click{{getter}}()` / `type{{getter}}(text)` or similar.\",\n\t\t},\n\t\tschema: [],\n\t},\n\tcreate(context) {\n\t\treturn {\n\t\t\tCallExpression(node: CallExpression) {\n\t\t\t\tif (node.callee.type !== \"MemberExpression\") return;\n\t\t\t\tconst callee = node.callee as MemberExpression;\n\t\t\t\tif (callee.computed || callee.property.type !== \"Identifier\") return;\n\n\t\t\t\tconst methodName = callee.property.name;\n\t\t\t\tif (!LOCATOR_ACTIONS.has(methodName)) return;\n\n\t\t\t\tconst getterName = getPomGetterName(callee.object as Expression);\n\t\t\t\tif (!getterName) return;\n\n\t\t\t\tcontext.report({\n\t\t\t\t\tnode,\n\t\t\t\t\tmessageId: \"noRawAction\",\n\t\t\t\t\tdata: { getter: getterName, method: methodName },\n\t\t\t\t});\n\t\t\t},\n\t\t};\n\t},\n};\n\nexport const plugin = {\n\trules: {\n\t\t\"no-raw-locator-action\": noRawLocatorActionRule,\n\t},\n} satisfies { rules: Record<string, Rule.RuleModule> };\n"],"names":[],"mappings":";;AAOA,MAAM,sCAAsB,IAAI;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,CAAC;AAMD,MAAM,oCAAoB,IAAI,CAAC,QAAQ,SAAS,OAAO,QAAQ,CAAC;AAWhE,SAAS,iBAAiB,MAAiC;AAC1D,MAAI,KAAK,SAAS,sBAAsB,CAAC,KAAK,YAAY,KAAK,SAAS,SAAS,cAAc;AAC9F,UAAM,OAAO,KAAK,SAAS;AAC3B,QAAI,SAAS,KAAK,IAAI,EAAG,QAAO;AAAA,EACjC;AAEA,MACC,KAAK,SAAS,oBACX,KAAK,OAAO,SAAS,sBACrB,CAAC,KAAK,OAAO,YACb,KAAK,OAAO,SAAS,SAAS,gBAC9B,cAAc,IAAI,KAAK,OAAO,SAAS,IAAI,GAC7C;AACD,WAAO,iBAAkB,KAAK,OAA4B,MAAoB;AAAA,EAC/E;AAEA,SAAO;AACR;AAEO,MAAM,yBAA0C;AAAA,EACtD,MAAM;AAAA,IACL,MAAM;AAAA,IACN,MAAM;AAAA,MACL,aACC;AAAA,IAAA;AAAA,IAEF,UAAU;AAAA,MACT,aACC;AAAA,IAAA;AAAA,IAGF,QAAQ,CAAA;AAAA,EAAC;AAAA,EAEV,OAAO,SAAS;AACf,WAAO;AAAA,MACN,eAAe,MAAsB;AACpC,YAAI,KAAK,OAAO,SAAS,mBAAoB;AAC7C,cAAM,SAAS,KAAK;AACpB,YAAI,OAAO,YAAY,OAAO,SAAS,SAAS,aAAc;AAE9D,cAAM,aAAa,OAAO,SAAS;AACnC,YAAI,CAAC,gBAAgB,IAAI,UAAU,EAAG;AAEtC,cAAM,aAAa,iBAAiB,OAAO,MAAoB;AAC/D,YAAI,CAAC,WAAY;AAEjB,gBAAQ,OAAO;AAAA,UACd;AAAA,UACA,WAAW;AAAA,UACX,MAAM,EAAE,QAAQ,YAAY,QAAQ,WAAA;AAAA,QAAW,CAC/C;AAAA,MACF;AAAA,IAAA;AAAA,EAEF;AACD;AAEO,MAAM,SAAS;AAAA,EACrB,OAAO;AAAA,IACN,yBAAyB;AAAA,EAAA;AAE3B;;;"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const LOCATOR_ACTIONS = /* @__PURE__ */ new Set([
|
|
2
|
+
"click",
|
|
3
|
+
"dblclick",
|
|
4
|
+
"fill",
|
|
5
|
+
"check",
|
|
6
|
+
"uncheck",
|
|
7
|
+
"type",
|
|
8
|
+
"clear",
|
|
9
|
+
"selectOption",
|
|
10
|
+
"setInputFiles",
|
|
11
|
+
"tap",
|
|
12
|
+
"hover",
|
|
13
|
+
"focus",
|
|
14
|
+
"dispatchEvent",
|
|
15
|
+
"press",
|
|
16
|
+
"selectText"
|
|
17
|
+
]);
|
|
18
|
+
const CHAIN_METHODS = /* @__PURE__ */ new Set(["last", "first", "nth", "filter"]);
|
|
19
|
+
function getPomGetterName(node) {
|
|
20
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
|
|
21
|
+
const name = node.property.name;
|
|
22
|
+
if (/^[A-Z]/.test(name)) return name;
|
|
23
|
+
}
|
|
24
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier" && CHAIN_METHODS.has(node.callee.property.name)) {
|
|
25
|
+
return getPomGetterName(node.callee.object);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const noRawLocatorActionRule = {
|
|
30
|
+
meta: {
|
|
31
|
+
type: "suggestion",
|
|
32
|
+
docs: {
|
|
33
|
+
description: "Disallow calling raw Playwright action methods directly on POM element getters. Use the generated typed POM methods instead (e.g. `clickSubmitButton()`)."
|
|
34
|
+
},
|
|
35
|
+
messages: {
|
|
36
|
+
noRawAction: "Use the generated POM method instead of `{{getter}}.{{method}}()`. Call `click{{getter}}()` / `type{{getter}}(text)` or similar."
|
|
37
|
+
},
|
|
38
|
+
schema: []
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
return {
|
|
42
|
+
CallExpression(node) {
|
|
43
|
+
if (node.callee.type !== "MemberExpression") return;
|
|
44
|
+
const callee = node.callee;
|
|
45
|
+
if (callee.computed || callee.property.type !== "Identifier") return;
|
|
46
|
+
const methodName = callee.property.name;
|
|
47
|
+
if (!LOCATOR_ACTIONS.has(methodName)) return;
|
|
48
|
+
const getterName = getPomGetterName(callee.object);
|
|
49
|
+
if (!getterName) return;
|
|
50
|
+
context.report({
|
|
51
|
+
node,
|
|
52
|
+
messageId: "noRawAction",
|
|
53
|
+
data: { getter: getterName, method: methodName }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const plugin = {
|
|
60
|
+
rules: {
|
|
61
|
+
"no-raw-locator-action": noRawLocatorActionRule
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
export {
|
|
65
|
+
noRawLocatorActionRule,
|
|
66
|
+
plugin
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":["../../eslint/index.ts"],"sourcesContent":["import type { Rule } from \"eslint\";\nimport type { CallExpression, Expression, MemberExpression } from \"estree\";\n\n/**\n * Playwright locator action methods that should be called via generated POM\n * methods rather than directly on element getters.\n */\nconst LOCATOR_ACTIONS = new Set([\n\t\"click\",\n\t\"dblclick\",\n\t\"fill\",\n\t\"check\",\n\t\"uncheck\",\n\t\"type\",\n\t\"clear\",\n\t\"selectOption\",\n\t\"setInputFiles\",\n\t\"tap\",\n\t\"hover\",\n\t\"focus\",\n\t\"dispatchEvent\",\n\t\"press\",\n\t\"selectText\",\n]);\n\n/**\n * Locator chain methods that are transparent for the purposes of this rule —\n * `.last().click()` is still a raw action on a POM getter.\n */\nconst CHAIN_METHODS = new Set([\"last\", \"first\", \"nth\", \"filter\"]);\n\n/**\n * Returns the PascalCase getter name if `node` is (or chains from) a direct\n * PascalCase member-expression access. Returns null otherwise.\n *\n * Handles:\n * pom.SubmitButton → \"SubmitButton\"\n * pom.SubmitButton.last() → \"SubmitButton\"\n * pom.SubmitButton.nth(0) → \"SubmitButton\"\n */\nfunction getPomGetterName(node: Expression): string | null {\n\tif (node.type === \"MemberExpression\" && !node.computed && node.property.type === \"Identifier\") {\n\t\tconst name = node.property.name;\n\t\tif (/^[A-Z]/.test(name)) return name;\n\t}\n\n\tif (\n\t\tnode.type === \"CallExpression\"\n\t\t&& node.callee.type === \"MemberExpression\"\n\t\t&& !node.callee.computed\n\t\t&& node.callee.property.type === \"Identifier\"\n\t\t&& CHAIN_METHODS.has(node.callee.property.name)\n\t) {\n\t\treturn getPomGetterName((node.callee as MemberExpression).object as Expression);\n\t}\n\n\treturn null;\n}\n\nexport const noRawLocatorActionRule: Rule.RuleModule = {\n\tmeta: {\n\t\ttype: \"suggestion\",\n\t\tdocs: {\n\t\t\tdescription:\n\t\t\t\t\"Disallow calling raw Playwright action methods directly on POM element getters. Use the generated typed POM methods instead (e.g. `clickSubmitButton()`).\",\n\t\t},\n\t\tmessages: {\n\t\t\tnoRawAction:\n\t\t\t\t\"Use the generated POM method instead of `{{getter}}.{{method}}()`. \"\n\t\t\t\t+ \"Call `click{{getter}}()` / `type{{getter}}(text)` or similar.\",\n\t\t},\n\t\tschema: [],\n\t},\n\tcreate(context) {\n\t\treturn {\n\t\t\tCallExpression(node: CallExpression) {\n\t\t\t\tif (node.callee.type !== \"MemberExpression\") return;\n\t\t\t\tconst callee = node.callee as MemberExpression;\n\t\t\t\tif (callee.computed || callee.property.type !== \"Identifier\") return;\n\n\t\t\t\tconst methodName = callee.property.name;\n\t\t\t\tif (!LOCATOR_ACTIONS.has(methodName)) return;\n\n\t\t\t\tconst getterName = getPomGetterName(callee.object as Expression);\n\t\t\t\tif (!getterName) return;\n\n\t\t\t\tcontext.report({\n\t\t\t\t\tnode,\n\t\t\t\t\tmessageId: \"noRawAction\",\n\t\t\t\t\tdata: { getter: getterName, method: methodName },\n\t\t\t\t});\n\t\t\t},\n\t\t};\n\t},\n};\n\nexport const plugin = {\n\trules: {\n\t\t\"no-raw-locator-action\": noRawLocatorActionRule,\n\t},\n} satisfies { rules: Record<string, Rule.RuleModule> };\n"],"names":[],"mappings":"AAOA,MAAM,sCAAsB,IAAI;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,CAAC;AAMD,MAAM,oCAAoB,IAAI,CAAC,QAAQ,SAAS,OAAO,QAAQ,CAAC;AAWhE,SAAS,iBAAiB,MAAiC;AAC1D,MAAI,KAAK,SAAS,sBAAsB,CAAC,KAAK,YAAY,KAAK,SAAS,SAAS,cAAc;AAC9F,UAAM,OAAO,KAAK,SAAS;AAC3B,QAAI,SAAS,KAAK,IAAI,EAAG,QAAO;AAAA,EACjC;AAEA,MACC,KAAK,SAAS,oBACX,KAAK,OAAO,SAAS,sBACrB,CAAC,KAAK,OAAO,YACb,KAAK,OAAO,SAAS,SAAS,gBAC9B,cAAc,IAAI,KAAK,OAAO,SAAS,IAAI,GAC7C;AACD,WAAO,iBAAkB,KAAK,OAA4B,MAAoB;AAAA,EAC/E;AAEA,SAAO;AACR;AAEO,MAAM,yBAA0C;AAAA,EACtD,MAAM;AAAA,IACL,MAAM;AAAA,IACN,MAAM;AAAA,MACL,aACC;AAAA,IAAA;AAAA,IAEF,UAAU;AAAA,MACT,aACC;AAAA,IAAA;AAAA,IAGF,QAAQ,CAAA;AAAA,EAAC;AAAA,EAEV,OAAO,SAAS;AACf,WAAO;AAAA,MACN,eAAe,MAAsB;AACpC,YAAI,KAAK,OAAO,SAAS,mBAAoB;AAC7C,cAAM,SAAS,KAAK;AACpB,YAAI,OAAO,YAAY,OAAO,SAAS,SAAS,aAAc;AAE9D,cAAM,aAAa,OAAO,SAAS;AACnC,YAAI,CAAC,gBAAgB,IAAI,UAAU,EAAG;AAEtC,cAAM,aAAa,iBAAiB,OAAO,MAAoB;AAC/D,YAAI,CAAC,WAAY;AAEjB,gBAAQ,OAAO;AAAA,UACd;AAAA,UACA,WAAW;AAAA,UACX,MAAM,EAAE,QAAQ,YAAY,QAAQ,WAAA;AAAA,QAAW,CAC/C;AAAA,MACF;AAAA,IAAA;AAAA,EAEF;AACD;AAEO,MAAM,SAAS;AAAA,EACrB,OAAO;AAAA,IACN,yBAAyB;AAAA,EAAA;AAE3B;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@immense/vue-pom-generator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Injects data-testid attributes for all interactive elements and generates page object models for every page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"import": "./dist/index.mjs",
|
|
25
25
|
"require": "./dist/index.cjs"
|
|
26
|
+
},
|
|
27
|
+
"./eslint": {
|
|
28
|
+
"types": "./dist/eslint/index.d.ts",
|
|
29
|
+
"import": "./dist/eslint/index.mjs",
|
|
30
|
+
"require": "./dist/eslint/index.cjs"
|
|
26
31
|
}
|
|
27
32
|
},
|
|
28
33
|
"files": [
|
|
@@ -49,10 +54,16 @@
|
|
|
49
54
|
},
|
|
50
55
|
"peerDependencies": {
|
|
51
56
|
"@vitejs/plugin-vue": ">=5",
|
|
57
|
+
"eslint": ">=9",
|
|
52
58
|
"vite": ">=7",
|
|
53
59
|
"vitest": "^4",
|
|
54
60
|
"vue": ">=3"
|
|
55
61
|
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"eslint": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
56
67
|
"dependencies": {
|
|
57
68
|
"@babel/parser": "^7.28.5",
|
|
58
69
|
"@babel/types": "^7.28.5",
|