@immense/vue-pom-generator 1.0.12 → 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 CHANGED
@@ -1,28 +1,39 @@
1
- ## Highlights
1
+ ```markdown
2
+ # v1.0.14
2
3
 
3
- - Fixed component name conversion to properly use PascalCase in TypeScript class declarations
4
- - Improved test suite by removing invalid `aggregated` option from test calls
5
- - Added PR release-notes preview comments to streamline the release process
4
+ ## Highlights
5
+
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
6
10
 
7
11
  ## Changes
8
12
 
9
- **Bug Fixes**
10
- - Convert component names to PascalCase in TypeScript class declarations
11
- - Remove invalid `aggregated` option from `GenerateFilesOptions` call in tests
13
+ ### Features
14
+ - Animated cursor overlay for Playwright recordings to improve test visualization
15
+ - Enhanced `class-generation/Pointer.ts` with significant improvements (249 lines)
16
+
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`)
12
22
 
13
- **CI/Tooling**
14
- - Add automated PR release-notes preview comments
23
+ ### Automation
24
+ - PR release notes preview comments automatically generated on pull requests
15
25
 
16
26
  ## Breaking Changes
17
27
 
18
- None.
28
+ None
19
29
 
20
30
  ## Pull Requests Included
21
31
 
22
- - [#1](https://github.com/immense/vue-pom-generator/pull/1) Add PR release-notes preview
23
- comments (@dkattan)
32
+ - #1 Add PR release-notes preview comments (https://github.com/immense/vue-pom-generator/pull/1)
33
+ by @dkattan
24
34
 
25
35
  ## Testing
26
36
 
27
- Test coverage expanded with 70 new lines added to `class-generation-coverage.test.ts`.
37
+ Testing details not provided in release window.
38
+ ```
28
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
- * When false, cursor animations are disabled (but clicks/fills still happen).
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 in ms before performing the action.
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 = { enabled: true, extraDelayMs: 0 };
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 instrumented”.
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
- // If the element detaches during navigation, let the subsequent click/fill surface the error.
183
+ // Element may detach during navigation; let the subsequent action surface the error.
82
184
  }
83
185
 
84
- const totalDelay = Math.max(0, delayMs) + Math.max(0, animationOptions.extraDelayMs ?? 0);
85
- if (animationOptions.enabled !== false && totalDelay > 0) {
86
- await this.page.waitForTimeout(totalDelay);
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
- try {
92
- clickedTestId = await this.getTestId(locator);
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
- catch {
95
- clickedTestId = undefined;
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
- // Reuse the click flow so the afterClick callback observes the click.
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
- await locator.first().fill(text);
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
  }
@@ -603,7 +603,25 @@ function generateAggregatedCSharpFiles(
603
603
  const baseGetterName = upperFirst(pom.getterNameOverride ?? pom.methodName);
604
604
  const locatorName = baseGetterName.endsWith(roleSuffix) ? baseGetterName : `${baseGetterName}${roleSuffix}`;
605
605
  const testIdExpr = toCSharpTestIdExpression(pom.formattedDataTestId);
606
- const { signature, argNames } = formatCSharpParams(pom.params);
606
+
607
+ // Ensure all template variables referenced in formattedDataTestId (e.g. `${key}`)
608
+ // appear in the C# method signature. utils.ts may omit `key` for input/select
609
+ // elements even when the test ID is dynamic, causing CS0103 compile errors.
610
+ const templateVarMatches = [...pom.formattedDataTestId.matchAll(/\$\{(\w+)\}/g)];
611
+ const templateVars = templateVarMatches.map(m => m[1]);
612
+ const augmentedParams: Record<string, string> = { ...pom.params };
613
+ for (const v of templateVars) {
614
+ if (!Object.prototype.hasOwnProperty.call(augmentedParams, v)) {
615
+ augmentedParams[v] = "string";
616
+ }
617
+ }
618
+ // Place template vars first so they precede text/value/annotationText.
619
+ const orderedParams: Record<string, string> = Object.fromEntries([
620
+ ...templateVars.map(v => [v, augmentedParams[v]] as [string, string]),
621
+ ...Object.entries(augmentedParams).filter(([k]) => !templateVars.includes(k)),
622
+ ]);
623
+
624
+ const { signature, argNames } = formatCSharpParams(orderedParams);
607
625
  const args = argNames.join(", ");
608
626
 
609
627
  const allTestIds = [pom.formattedDataTestId, ...(pom.alternateFormattedDataTestIds ?? [])]
package/dist/index.cjs CHANGED
@@ -1992,18 +1992,18 @@ Fix: make the element identifiable (e.g. add id/name/inner text or use a more sp
1992
1992
  case "input":
1993
1993
  params.text = "string";
1994
1994
  params.annotationText = 'string = ""';
1995
- delete params.key;
1995
+ if (!isKeyed) delete params.key;
1996
1996
  break;
1997
1997
  case "select":
1998
1998
  params.value = "string";
1999
1999
  params.annotationText = 'string = ""';
2000
- delete params.key;
2000
+ if (!isKeyed) delete params.key;
2001
2001
  break;
2002
2002
  case "vselect":
2003
2003
  params.value = "string";
2004
2004
  params.timeOut = "number = 500";
2005
2005
  params.annotationText = 'string = ""';
2006
- delete params.key;
2006
+ if (!isKeyed) delete params.key;
2007
2007
  break;
2008
2008
  case "radio":
2009
2009
  params.annotationText = 'string = ""';
@@ -2071,11 +2071,11 @@ Fix: make the element identifiable (e.g. add id/name/inner text or use a more sp
2071
2071
  }
2072
2072
  switch (role) {
2073
2073
  case "input":
2074
- return { params: 'text: string, annotationText: string = ""', argNames: ["text", "annotationText"] };
2074
+ return needsKey2 ? { params: `key: ${keyType}, text: string, annotationText: string = ""`, argNames: ["key", "text", "annotationText"] } : { params: 'text: string, annotationText: string = ""', argNames: ["text", "annotationText"] };
2075
2075
  case "select":
2076
- return { params: 'value: string, annotationText: string = ""', argNames: ["value", "annotationText"] };
2076
+ return needsKey2 ? { params: `key: ${keyType}, value: string, annotationText: string = ""`, argNames: ["key", "value", "annotationText"] } : { params: 'value: string, annotationText: string = ""', argNames: ["value", "annotationText"] };
2077
2077
  case "vselect":
2078
- return { params: "value: string, timeOut = 500", argNames: ["value", "timeOut"] };
2078
+ return needsKey2 ? { params: `key: ${keyType}, value: string, timeOut = 500`, argNames: ["key", "value", "timeOut"] } : { params: "value: string, timeOut = 500", argNames: ["value", "timeOut"] };
2079
2079
  case "radio":
2080
2080
  return needsKey2 ? { params: `key: ${keyType}, annotationText: string = ""`, argNames: ["key", "annotationText"] } : { params: 'annotationText: string = ""', argNames: ["annotationText"] };
2081
2081
  default:
@@ -3084,7 +3084,19 @@ function generateAggregatedCSharpFiles(componentHierarchyMap, outDir, options =
3084
3084
  const baseGetterName = upperFirst(pom.getterNameOverride ?? pom.methodName);
3085
3085
  const locatorName = baseGetterName.endsWith(roleSuffix) ? baseGetterName : `${baseGetterName}${roleSuffix}`;
3086
3086
  const testIdExpr = toCSharpTestIdExpression(pom.formattedDataTestId);
3087
- const { signature, argNames } = formatCSharpParams(pom.params);
3087
+ const templateVarMatches = [...pom.formattedDataTestId.matchAll(/\$\{(\w+)\}/g)];
3088
+ const templateVars = templateVarMatches.map((m) => m[1]);
3089
+ const augmentedParams = { ...pom.params };
3090
+ for (const v of templateVars) {
3091
+ if (!Object.prototype.hasOwnProperty.call(augmentedParams, v)) {
3092
+ augmentedParams[v] = "string";
3093
+ }
3094
+ }
3095
+ const orderedParams = Object.fromEntries([
3096
+ ...templateVars.map((v) => [v, augmentedParams[v]]),
3097
+ ...Object.entries(augmentedParams).filter(([k]) => !templateVars.includes(k))
3098
+ ]);
3099
+ const { signature, argNames } = formatCSharpParams(orderedParams);
3088
3100
  const args = argNames.join(", ");
3089
3101
  const allTestIds = [pom.formattedDataTestId, ...pom.alternateFormattedDataTestIds ?? []].filter((v, idx, arr) => v && arr.indexOf(v) === idx);
3090
3102
  if (pom.formattedDataTestId.includes("${")) {
@@ -4599,6 +4611,37 @@ function createTestIdTransform(componentName, componentHierarchyMap, nativeWrapp
4599
4611
  }
4600
4612
  };
4601
4613
  }
4614
+ function resolveComponentNameFromPath(options) {
4615
+ const { projectRoot, viewsDirAbs, scanDirs, extraRoots = [] } = options;
4616
+ const cleanFilename = options.filename.includes("?") ? options.filename.substring(0, options.filename.indexOf("?")) : options.filename;
4617
+ const absFilename = path.isAbsolute(cleanFilename) ? cleanFilename : path.resolve(projectRoot, cleanFilename);
4618
+ const rootBases = [projectRoot, ...extraRoots.filter((r) => r !== projectRoot)];
4619
+ const roots = [viewsDirAbs, ...rootBases.flatMap((base) => scanDirs.map((d) => path.resolve(base, d)))];
4620
+ for (const base of rootBases) {
4621
+ for (const dir of scanDirs) {
4622
+ const absDir = path.resolve(base, dir);
4623
+ try {
4624
+ const pagesDir = path.join(absDir, "pages");
4625
+ if (fs.existsSync(pagesDir))
4626
+ roots.push(pagesDir);
4627
+ const componentsDir = path.join(absDir, "components");
4628
+ if (fs.existsSync(componentsDir))
4629
+ roots.push(componentsDir);
4630
+ } catch {
4631
+ }
4632
+ }
4633
+ }
4634
+ const potentialRoots = Array.from(new Set(roots.map((r) => path.normalize(r)))).sort((a, b) => b.length - a.length);
4635
+ for (const root of potentialRoots) {
4636
+ if (absFilename.startsWith(root + path.sep) || absFilename === root) {
4637
+ const rel = path.relative(root, absFilename);
4638
+ const parsed = path.parse(rel);
4639
+ const segments = path.join(parsed.dir, parsed.name);
4640
+ return toPascalCase(segments);
4641
+ }
4642
+ }
4643
+ return toPascalCase(path.parse(absFilename).name);
4644
+ }
4602
4645
  function createDevProcessorPlugin(options) {
4603
4646
  const {
4604
4647
  nativeWrappers,
@@ -4717,7 +4760,13 @@ function createDevProcessorPlugin(options) {
4717
4760
  const existing = filePathToComponentName.get(normalized);
4718
4761
  if (existing)
4719
4762
  return existing;
4720
- const name = path.basename(normalized, ".vue");
4763
+ const name = resolveComponentNameFromPath({
4764
+ filename: normalized,
4765
+ projectRoot: projectRootRef.current,
4766
+ viewsDirAbs: getViewsDirAbs(),
4767
+ scanDirs,
4768
+ extraRoots: [process.cwd()]
4769
+ });
4721
4770
  filePathToComponentName.set(normalized, name);
4722
4771
  return name;
4723
4772
  };
@@ -5202,41 +5251,13 @@ function createVuePluginWithTestIds(options) {
5202
5251
  getProjectRoot
5203
5252
  } = options;
5204
5253
  const getComponentNameFromPath = (filename) => {
5205
- const cleanPath = filename.includes("?") ? filename.substring(0, filename.indexOf("?")) : filename;
5206
- const projectRoot = getProjectRoot();
5207
- const absFilename = path.isAbsolute(cleanPath) ? cleanPath : path.resolve(projectRoot, cleanPath);
5208
- const viewsDirAbs = getViewsDirAbs();
5209
- const roots = [viewsDirAbs, ...scanDirs.map((d) => path.resolve(projectRoot, d))];
5210
- for (const dir of scanDirs) {
5211
- const absDir = path.resolve(projectRoot, dir);
5212
- try {
5213
- const pagesDir = path.join(absDir, "pages");
5214
- if (fs.existsSync(pagesDir)) {
5215
- roots.push(pagesDir);
5216
- }
5217
- const componentsDir = path.join(absDir, "components");
5218
- if (fs.existsSync(componentsDir)) {
5219
- roots.push(componentsDir);
5220
- }
5221
- } catch {
5222
- }
5223
- }
5224
- const potentialRoots = Array.from(new Set(roots.map((r) => path.normalize(r)))).sort((a, b) => b.length - a.length);
5225
- let componentName = "";
5226
- for (const root of potentialRoots) {
5227
- if (absFilename.startsWith(root + path.sep) || absFilename === root) {
5228
- const rel = path.relative(root, absFilename);
5229
- const parsed = path.parse(rel);
5230
- const segments = path.join(parsed.dir, parsed.name);
5231
- componentName = toPascalCase(segments);
5232
- break;
5233
- }
5234
- }
5235
- if (!componentName) {
5236
- const parsed = path.parse(absFilename);
5237
- componentName = toPascalCase(parsed.name);
5238
- }
5239
- return componentName;
5254
+ return resolveComponentNameFromPath({
5255
+ filename,
5256
+ projectRoot: getProjectRoot(),
5257
+ viewsDirAbs: getViewsDirAbs(),
5258
+ scanDirs,
5259
+ extraRoots: [process.cwd()]
5260
+ });
5240
5261
  };
5241
5262
  const isFileInScope = (filename) => {
5242
5263
  if (!filename)