@immense/vue-pom-generator 1.0.3

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.
@@ -0,0 +1,1691 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { generateViewObjectModelMethodContent } from "../method-generation";
6
+ import { parseRouterFileFromCwd } from "../router-introspection";
7
+ // NOTE: This module intentionally does not depend on Babel parsing.
8
+
9
+ import { IComponentDependencies, IDataTestId, PomExtraClickMethodSpec, PomPrimarySpec, upperFirst } from "../utils";
10
+
11
+ // Intentionally imported so tooling understands this exported helper is part of the
12
+ // generated POM public surface (it is consumed by generated Playwright fixtures).
13
+ import { setPlaywrightAnimationOptions } from "./Pointer";
14
+
15
+ void setPlaywrightAnimationOptions;
16
+
17
+ export { generateViewObjectModelMethodContent };
18
+
19
+ const AUTO_GENERATED_COMMENT
20
+ = " * DO NOT MODIFY BY HAND\n"
21
+ + " *\n"
22
+ + " * This file is auto-generated by vue-pom-generator.\n"
23
+ + " * Changes should be made in the generator/template, not in the generated output.\n"
24
+ + " */";
25
+ const eslintSuppressionHeader = "/* eslint-disable perfectionist/sort-imports */\n";
26
+
27
+ function toPosixRelativePath(fromDir: string, toFile: string): string {
28
+ let rel = path.relative(fromDir, toFile).replace(/\\/g, "/");
29
+ if (!rel.startsWith(".")) {
30
+ rel = `./${rel}`;
31
+ }
32
+ return rel;
33
+ }
34
+
35
+ function changeExtension(filePath: string, expectedExt: string, nextExtWithDot: string): string {
36
+ const parsed = path.parse(filePath);
37
+ if (parsed.ext !== expectedExt)
38
+ return filePath;
39
+ return path.format({ ...parsed, base: `${parsed.name}${nextExtWithDot}`, ext: nextExtWithDot });
40
+ }
41
+
42
+ function stripExtension(filePath: string): string {
43
+ // IMPORTANT:
44
+ // This helper is used for generating *import specifiers*.
45
+ // On Windows, `path.parse/path.format` can re-introduce backslashes even when
46
+ // the input contains `/` separators, producing invalid TS string escapes like `"..\\pom\\custom\\nGrid"`.
47
+ // Keep these paths POSIX-normalized.
48
+ const posix = (filePath ?? "").replace(/\\/g, "/");
49
+ const parsed = path.posix.parse(posix);
50
+ return path.posix.format({ ...parsed, base: parsed.name, ext: "" });
51
+ }
52
+
53
+ function resolveRouterEntry(projectRoot?: string, routerEntry?: string) {
54
+ if (!routerEntry) {
55
+ throw new Error("[vue-pom-generator] Router entry path is required when routerAwarePoms is enabled.");
56
+ }
57
+ const root = projectRoot ?? process.cwd();
58
+ return path.isAbsolute(routerEntry) ? routerEntry : path.resolve(root, routerEntry);
59
+ }
60
+
61
+ interface RouteMeta {
62
+ template: string;
63
+ }
64
+
65
+ async function getRouteMetaByComponent(projectRoot?: string, routerEntry?: string): Promise<Record<string, RouteMeta>> {
66
+ const resolvedRouterEntry = resolveRouterEntry(projectRoot, routerEntry);
67
+ const { routeMetaEntries } = await parseRouterFileFromCwd(resolvedRouterEntry);
68
+
69
+ const map = new Map<string, RouteMeta[]>();
70
+ for (const entry of routeMetaEntries) {
71
+ const list = map.get(entry.componentName) ?? [];
72
+ list.push({ template: entry.pathTemplate });
73
+ map.set(entry.componentName, list);
74
+ }
75
+
76
+ const chooseRouteMeta = (entries: RouteMeta[]): RouteMeta | null => {
77
+ if (!entries.length)
78
+ return null;
79
+ return entries
80
+ .slice()
81
+ .sort((a, b) => a.template.length - b.template.length || a.template.localeCompare(b.template))[0];
82
+ };
83
+
84
+ const sorted = Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
85
+ return Object.fromEntries(
86
+ sorted
87
+ .map(([componentName, entries]) => {
88
+ const chosen = chooseRouteMeta(entries);
89
+ return chosen ? [componentName, chosen] : null;
90
+ })
91
+ .filter((entry): entry is [string, RouteMeta] => !!entry),
92
+ );
93
+ }
94
+
95
+ function generateRouteProperty(routeMeta: RouteMeta | null): string {
96
+ if (!routeMeta) {
97
+ return " static readonly route: { template: string } | null = null;\n";
98
+ }
99
+
100
+ return [
101
+ " static readonly route: { template: string } | null = {",
102
+ ` template: ${JSON.stringify(routeMeta.template)},`,
103
+ " } as const;",
104
+ "",
105
+ ].join("\n");
106
+ }
107
+
108
+ function generateGoToSelfMethod(componentName: string): string {
109
+ return [
110
+ "",
111
+ " async goTo() {",
112
+ " await this.goToSelf();",
113
+ " }",
114
+ "",
115
+ " async goToSelf() {",
116
+ ` const route = ${componentName}.route;`,
117
+ " if (!route) {",
118
+ ` throw new Error("[pom] No router path found for component/page-object '${componentName}'.");`,
119
+ " }",
120
+ " await this.page.goto(route.template);",
121
+ " }",
122
+ "",
123
+ ].join("\n");
124
+ }
125
+
126
+ function formatMethodParams(params: Record<string, string> | undefined): string {
127
+ if (!params)
128
+ return "";
129
+
130
+ // Keep output stable and somewhat intuitive.
131
+ const preferredOrder = ["key", "value", "text", "timeOut", "annotationText", "wait"];
132
+
133
+ const entries = Object.entries(params);
134
+ if (!entries.length)
135
+ return "";
136
+
137
+ const score = (name: string) => {
138
+ const idx = preferredOrder.indexOf(name);
139
+ return idx < 0 ? 999 : idx;
140
+ };
141
+
142
+ return entries
143
+ .slice()
144
+ .sort((a, b) => score(a[0]) - score(b[0]) || a[0].localeCompare(b[0]))
145
+ .map(([name, typeExpr]) => `${name}: ${typeExpr}`)
146
+ .join(", ");
147
+ }
148
+
149
+ function generateExtraClickMethodContent(spec: PomExtraClickMethodSpec): string {
150
+ if (spec.kind !== "click") {
151
+ return "";
152
+ }
153
+
154
+ const params = spec.params ?? {};
155
+ const signatureParams = formatMethodParams(params);
156
+ const signature = signatureParams ? `(${signatureParams})` : "()";
157
+
158
+ const needsTemplate = spec.formattedDataTestId.includes("${");
159
+ const testIdExpr = needsTemplate
160
+ ? `\`${spec.formattedDataTestId}\``
161
+ : JSON.stringify(spec.formattedDataTestId);
162
+
163
+ const lines: string[] = [];
164
+ lines.push(
165
+ "",
166
+ ` async ${spec.name}${signature} {`,
167
+ );
168
+
169
+ if (spec.keyLiteral !== undefined) {
170
+ lines.push(` const key = ${JSON.stringify(spec.keyLiteral)};`);
171
+ }
172
+
173
+ if (needsTemplate) {
174
+ lines.push(` const testId = ${testIdExpr};`);
175
+ }
176
+
177
+ // clickByTestId(testId, annotationText = "", wait = true)
178
+ const hasAnnotationText = Object.prototype.hasOwnProperty.call(params, "annotationText");
179
+ const hasWait = Object.prototype.hasOwnProperty.call(params, "wait");
180
+
181
+ const clickArgs: string[] = [];
182
+ clickArgs.push(needsTemplate ? "testId" : testIdExpr);
183
+
184
+ if (hasAnnotationText || hasWait) {
185
+ clickArgs.push(hasAnnotationText ? "annotationText" : "\"\"");
186
+ }
187
+ if (hasWait) {
188
+ clickArgs.push("wait");
189
+ }
190
+
191
+ lines.push(` await this.clickByTestId(${clickArgs.join(", ")});`);
192
+ lines.push(" }");
193
+
194
+ return `${lines.join("\n")}\n`;
195
+ }
196
+
197
+ function generateMethodContentFromPom(primary: PomPrimarySpec, targetPageObjectModelClass?: string): string {
198
+ if (primary.emitPrimary === false) {
199
+ return "";
200
+ }
201
+
202
+ return generateViewObjectModelMethodContent(
203
+ targetPageObjectModelClass,
204
+ primary.methodName,
205
+ primary.nativeRole,
206
+ primary.formattedDataTestId,
207
+ primary.alternateFormattedDataTestIds,
208
+ primary.getterNameOverride,
209
+ primary.params ?? {},
210
+ );
211
+ }
212
+
213
+ function generateMethodsContentForDependencies(dependencies: IComponentDependencies): string {
214
+ const entries = Array.from(dependencies.dataTestIdSet ?? []);
215
+ const primarySpecsAll = entries
216
+ .map(e => ({ pom: e.pom, target: e.targetPageObjectModelClass }))
217
+ .filter((x): x is { pom: PomPrimarySpec; target: string | undefined } => !!x.pom)
218
+ .sort((a, b) => a.pom.methodName.localeCompare(b.pom.methodName));
219
+
220
+ // IMPORTANT:
221
+ // `dependencies.dataTestIdSet` is a Set of objects; it does not de-dupe by semantic identity.
222
+ // It's possible to end up with multiple IDataTestId entries that carry identical `pom` specs.
223
+ // When we emit from IR, we must de-dupe here to avoid duplicate getters/methods.
224
+ const seenPrimaryKeys = new Set<string>();
225
+ const primarySpecs = primarySpecsAll.filter(({ pom, target }) => {
226
+ const stableParams = pom.params
227
+ ? Object.fromEntries(Object.entries(pom.params).sort((a, b) => a[0].localeCompare(b[0])))
228
+ : undefined;
229
+ const alternates = (pom.alternateFormattedDataTestIds ?? []).slice().sort();
230
+ const key = JSON.stringify({
231
+ role: pom.nativeRole,
232
+ methodName: pom.methodName,
233
+ getterNameOverride: pom.getterNameOverride ?? null,
234
+ testId: pom.formattedDataTestId,
235
+ alternateTestIds: alternates.length ? alternates : undefined,
236
+ params: stableParams,
237
+ target: target ?? null,
238
+ emitPrimary: pom.emitPrimary ?? true,
239
+ });
240
+ if (seenPrimaryKeys.has(key)) {
241
+ return false;
242
+ }
243
+ seenPrimaryKeys.add(key);
244
+ return true;
245
+ });
246
+
247
+ const extras = (dependencies.pomExtraMethods ?? [])
248
+ .slice()
249
+ .sort((a, b) => a.name.localeCompare(b.name));
250
+
251
+ let content = "";
252
+ for (const { pom, target } of primarySpecs) {
253
+ content += generateMethodContentFromPom(pom, target);
254
+ }
255
+
256
+ for (const extra of extras) {
257
+ content += generateExtraClickMethodContent(extra);
258
+ }
259
+
260
+ return content;
261
+ }
262
+
263
+ export interface GenerateFilesOptions {
264
+ /**
265
+ * Output directory for generated files.
266
+ *
267
+ * Defaults to `./pom` when omitted (backwards compatible default for internal usage).
268
+ */
269
+ outDir?: string;
270
+
271
+ /**
272
+ * Generate Playwright fixture helpers alongside generated POMs.
273
+ *
274
+ * Default output (when `true`):
275
+ * - `<projectRoot>/<outDir>/fixtures.g.ts`
276
+ *
277
+ * Accepted values:
278
+ * - `true`: enable with defaults
279
+ * - `"path"`: enable and write the fixture file under this directory (resolved relative to projectRoot),
280
+ * or to this file path if it ends with `.ts`/`.tsx`/`.mts`/`.cts`
281
+ * - `{ outDir }`: enable and override where fixture files are written (resolved relative to projectRoot)
282
+ */
283
+ generateFixtures?: boolean | string | { outDir?: string };
284
+
285
+ /**
286
+ * Project root used for resolving conventional paths (e.g. src/views, tests/playwright/pom/custom).
287
+ * Defaults to process.cwd() for backwards compatibility.
288
+ */
289
+ projectRoot?: string;
290
+
291
+ /**
292
+ * Directory containing handwritten POM helpers to import into aggregated output.
293
+ * Defaults to <projectRoot>/tests/playwright/pom/custom.
294
+ */
295
+ customPomDir?: string;
296
+
297
+ /**
298
+ * Optional import aliases for handwritten POM helpers.
299
+ *
300
+ * Keyed by the helper file/export name (basename of the .ts file).
301
+ * Value is the identifier to import it as.
302
+ *
303
+ * Example: { Toggle: "ToggleWidget" }
304
+ */
305
+ customPomImportAliases?: Record<string, string>;
306
+
307
+ /**
308
+ * Handwritten POM helper attachments. These helpers are assumed to be present in the
309
+ * aggregated output (e.g. via `tests/playwright/pom/custom/*.ts` inlining), but we only attach them to
310
+ * view classes that actually use certain components.
311
+ */
312
+ customPomAttachments?: Array<{
313
+ className: string;
314
+ propertyName: string;
315
+ attachWhenUsesComponents: string[];
316
+
317
+ /**
318
+ * Controls whether this attachment is applied to views, components, or both.
319
+ * Defaults to "views" for backwards compatibility.
320
+ */
321
+ attachTo?: "views" | "components" | "both";
322
+ }>;
323
+
324
+ /** Attribute name to treat as the test id. Defaults to `data-testid`. */
325
+ testIdAttribute?: string;
326
+
327
+ /** Which POM languages to emit. Defaults to ["ts"]. */
328
+ emitLanguages?: Array<"ts" | "csharp">;
329
+
330
+ /** When true, generate router-aware helpers like goToSelf() on view POMs. */
331
+ vueRouterFluentChaining?: boolean;
332
+
333
+ /** Router entry path used for vue-router introspection when fluent chaining is enabled. */
334
+ routerEntry?: string;
335
+
336
+ routeMetaByComponent?: Record<string, RouteMeta>;
337
+ }
338
+
339
+ interface GenerateContentOptions {
340
+ /** Directory the generated .g.ts file will live in (used for relative imports). Defaults to the Vue file's directory. */
341
+ outputDir?: string;
342
+ /** When true, omit file headers/import blocks that should be shared in an aggregated file. */
343
+ aggregated?: boolean;
344
+
345
+ customPomAttachments?: Array<{
346
+ className: string;
347
+ propertyName: string;
348
+ attachWhenUsesComponents: string[];
349
+
350
+ /**
351
+ * Controls whether this attachment is applied to views, components, or both.
352
+ * Defaults to "views" for backwards compatibility.
353
+ */
354
+ attachTo?: "views" | "components" | "both";
355
+ }>;
356
+
357
+ projectRoot?: string;
358
+ customPomDir?: string;
359
+ customPomImportAliases?: Record<string, string>;
360
+
361
+ /** Attribute name to treat as the test id. Defaults to `data-testid`. */
362
+ testIdAttribute?: string;
363
+
364
+ /** When true, generate router-aware helpers like goToSelf() on view POMs. */
365
+ vueRouterFluentChaining?: boolean;
366
+
367
+ routeMetaByComponent?: Record<string, RouteMeta>;
368
+ }
369
+
370
+ export async function generateFiles(
371
+ componentHierarchyMap: Map<string, IComponentDependencies>,
372
+ vueFilesPathMap: Map<string, string>,
373
+ basePageClassPath: string,
374
+ options: GenerateFilesOptions = {},
375
+ ) {
376
+ const {
377
+ outDir: outDirOverride,
378
+ generateFixtures,
379
+ customPomAttachments = [],
380
+ projectRoot,
381
+ customPomDir,
382
+ customPomImportAliases,
383
+ testIdAttribute,
384
+ emitLanguages: emitLanguagesOverride,
385
+ vueRouterFluentChaining,
386
+ routerEntry,
387
+ } = options;
388
+
389
+ const emitLanguages: Array<"ts" | "csharp"> = emitLanguagesOverride?.length
390
+ ? emitLanguagesOverride
391
+ : ["ts"];
392
+
393
+ const outDir = outDirOverride ?? "./pom";
394
+
395
+ const routeMetaByComponent = vueRouterFluentChaining
396
+ ? await getRouteMetaByComponent(projectRoot, routerEntry)
397
+ : undefined;
398
+
399
+ if (emitLanguages.includes("ts")) {
400
+ const files = await generateAggregatedFiles(componentHierarchyMap, vueFilesPathMap, basePageClassPath, outDir, {
401
+ customPomAttachments,
402
+ projectRoot,
403
+ customPomDir,
404
+ customPomImportAliases,
405
+ testIdAttribute,
406
+ generateFixtures,
407
+ routeMetaByComponent,
408
+ vueRouterFluentChaining,
409
+ });
410
+ for (const file of files) {
411
+ createFile(file.filePath, file.content);
412
+ }
413
+
414
+ maybeGenerateFixtureRegistry(componentHierarchyMap, {
415
+ generateFixtures,
416
+ pomOutDir: outDir,
417
+ projectRoot,
418
+ });
419
+ }
420
+
421
+ if (emitLanguages.includes("csharp")) {
422
+ const csFiles = generateAggregatedCSharpFiles(componentHierarchyMap, outDir, {
423
+ projectRoot,
424
+ });
425
+ for (const file of csFiles) {
426
+ createFile(file.filePath, file.content);
427
+ }
428
+ }
429
+ }
430
+
431
+ function toCSharpTestIdExpression(formattedDataTestId: string): string {
432
+ // Convert our `${var}` placeholder format into C# interpolated-string `{var}`.
433
+ const needsInterpolation = formattedDataTestId.includes("${");
434
+ if (!needsInterpolation) {
435
+ return JSON.stringify(formattedDataTestId);
436
+ }
437
+
438
+ const inner = formattedDataTestId.replace(/\$\{/g, "{");
439
+ // Use verbatim JSON escaping for quotes/backslashes, then adapt to C# string literal.
440
+ // JSON.stringify gives us a JS string literal with escapes, which is close enough for a C# normal string.
441
+ const quoted = JSON.stringify(inner);
442
+ return `$${quoted}`;
443
+ }
444
+
445
+ function toCSharpParam(paramTypeExpr: string): { type: string; defaultExpr?: string } {
446
+ const trimmed = (paramTypeExpr ?? "").trim();
447
+
448
+ // Handle default values: "boolean = true", "string = \"\"", "timeOut = 500".
449
+ const eqIdx = trimmed.indexOf("=");
450
+ const left = eqIdx >= 0 ? trimmed.slice(0, eqIdx).trim() : trimmed;
451
+ const right = eqIdx >= 0 ? trimmed.slice(eqIdx + 1).trim() : undefined;
452
+
453
+ // Collapse union types to their widest practical type.
454
+ const typePart = left.includes("|") ? "string" : left;
455
+
456
+ let type = "string";
457
+ if (/(^|\s)boolean(\s|$)/.test(typePart))
458
+ type = "bool";
459
+ else if (/(^|\s)string(\s|$)/.test(typePart))
460
+ type = "string";
461
+ else if (/(^|\s)number(\s|$)/.test(typePart))
462
+ type = "int";
463
+ else if (/\d+/.test(typePart) && typePart === "")
464
+ type = "int";
465
+ else if (/\btimeOut\b/i.test(typePart))
466
+ type = "int";
467
+
468
+ let defaultExpr: string | undefined;
469
+ if (right !== undefined) {
470
+ if (type === "bool") {
471
+ defaultExpr = right.includes("true") ? "true" : right.includes("false") ? "false" : undefined;
472
+ }
473
+ else if (type === "int") {
474
+ const m = right.match(/\d+/);
475
+ defaultExpr = m ? m[0] : undefined;
476
+ }
477
+ else {
478
+ // string defaults, keep empty string if detected.
479
+ if (right === "\"\"" || right === "\"\"" || right === "''") {
480
+ defaultExpr = "\"\"";
481
+ }
482
+ }
483
+ }
484
+
485
+ return { type, defaultExpr };
486
+ }
487
+
488
+ function formatCSharpParams(params: Record<string, string> | undefined): { signature: string; argNames: string[] } {
489
+ if (!params)
490
+ return { signature: "", argNames: [] };
491
+
492
+ const entries = Object.entries(params);
493
+ if (!entries.length)
494
+ return { signature: "", argNames: [] };
495
+
496
+ const signatureParts: string[] = [];
497
+ const argNames: string[] = [];
498
+
499
+ for (const [name, typeExpr] of entries) {
500
+ const { type, defaultExpr } = toCSharpParam(typeExpr);
501
+ argNames.push(name);
502
+ signatureParts.push(defaultExpr !== undefined ? `${type} ${name} = ${defaultExpr}` : `${type} ${name}`);
503
+ }
504
+
505
+ return { signature: signatureParts.join(", "), argNames };
506
+ }
507
+
508
+ function generateAggregatedCSharpFiles(
509
+ componentHierarchyMap: Map<string, IComponentDependencies>,
510
+ outDir: string,
511
+ options: { projectRoot?: string } = {},
512
+ ): Array<{ filePath: string; content: string }> {
513
+ const projectRoot = options.projectRoot ?? process.cwd();
514
+ const outAbs = path.isAbsolute(outDir) ? outDir : path.resolve(projectRoot, outDir);
515
+
516
+ const entries = Array.from(componentHierarchyMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
517
+
518
+ const header = [
519
+ "// <auto-generated>",
520
+ "// DO NOT MODIFY BY HAND",
521
+ "//",
522
+ "// This file is auto-generated by vue-pom-generator.",
523
+ "// Changes should be made in the generator/template, not in the generated output.",
524
+ "// </auto-generated>",
525
+ "",
526
+ "using System;",
527
+ "using System.Threading.Tasks;",
528
+ "using Microsoft.Playwright;",
529
+ "",
530
+ "namespace ImmyBot.Playwright.Generated;",
531
+ "",
532
+ "public abstract class BasePage",
533
+ "{",
534
+ " protected BasePage(IPage page) => Page = page;",
535
+ " protected IPage Page { get; }",
536
+ " protected ILocator LocatorByTestId(string testId) => Page.GetByTestId(testId);",
537
+ "",
538
+ " // Minimal vue-select helper mirroring the TS BasePage.selectVSelectByTestId behavior.",
539
+ " // Note: annotationText is currently a no-op in C# output (we don't render a cursor overlay).",
540
+ " protected async Task SelectVSelectByTestIdAsync(string testId, string value, int timeOut = 500)",
541
+ " {",
542
+ " var root = LocatorByTestId(testId);",
543
+ " var input = root.Locator(\"input\");",
544
+ "",
545
+ " await input.ClickAsync(new LocatorClickOptions { Force = true });",
546
+ " await input.FillAsync(value);",
547
+ " await Page.WaitForTimeoutAsync(timeOut);",
548
+ "",
549
+ " var option = root.Locator(\"ul.vs__dropdown-menu li[role='option']\").First;",
550
+ " if (await option.CountAsync() > 0)",
551
+ " {",
552
+ " await option.ClickAsync();",
553
+ " }",
554
+ " }",
555
+ "}",
556
+ "",
557
+ ].join("\n");
558
+
559
+ const chunks: string[] = [header];
560
+
561
+ for (const [componentName, deps] of entries) {
562
+ chunks.push(
563
+ `public sealed class ${componentName} : BasePage\n{\n public ${componentName}(IPage page) : base(page) { }\n`,
564
+ );
565
+
566
+ // Primary specs
567
+ const primaries = Array.from(deps.dataTestIdSet ?? [])
568
+ .map(e => ({ pom: e.pom, target: e.targetPageObjectModelClass }))
569
+ .filter((x): x is { pom: PomPrimarySpec; target: string | undefined } => !!x.pom)
570
+ .sort((a, b) => a.pom.methodName.localeCompare(b.pom.methodName));
571
+
572
+ for (const { pom, target } of primaries) {
573
+ if (pom.emitPrimary === false)
574
+ continue;
575
+
576
+ const roleSuffix = (pom.nativeRole || "Element") === "vselect" ? "VSelect" : upperFirst(pom.nativeRole || "Element");
577
+ const baseMethodName = upperFirst(pom.methodName);
578
+ const baseGetterName = upperFirst(pom.getterNameOverride ?? pom.methodName);
579
+ const locatorName = baseGetterName.endsWith(roleSuffix) ? baseGetterName : `${baseGetterName}${roleSuffix}`;
580
+ const testIdExpr = toCSharpTestIdExpression(pom.formattedDataTestId);
581
+ const { signature, argNames } = formatCSharpParams(pom.params);
582
+ const args = argNames.join(", ");
583
+
584
+ const allTestIds = [pom.formattedDataTestId, ...(pom.alternateFormattedDataTestIds ?? [])]
585
+ .filter((v, idx, arr) => v && arr.indexOf(v) === idx);
586
+
587
+ if (pom.formattedDataTestId.includes("${")) {
588
+ chunks.push(` public ILocator ${locatorName}(${signature}) => LocatorByTestId(${testIdExpr});`);
589
+ }
590
+ else {
591
+ chunks.push(` public ILocator ${locatorName} => LocatorByTestId(${testIdExpr});`);
592
+ }
593
+
594
+ // Action method
595
+ const actionPrefix = pom.nativeRole === "input"
596
+ ? "Type"
597
+ : (pom.nativeRole === "select" || pom.nativeRole === "vselect" || pom.nativeRole === "radio")
598
+ ? "Select"
599
+ : target
600
+ ? "GoTo"
601
+ : "Click";
602
+
603
+ const actionName = `${actionPrefix}${baseMethodName}Async`;
604
+ const sig = signature;
605
+
606
+ if (target) {
607
+ chunks.push(` public async Task<${target}> ${actionName}(${sig})`);
608
+ chunks.push(" {");
609
+ if (pom.formattedDataTestId.includes("${") || allTestIds.length <= 1) {
610
+ chunks.push(` await ${locatorName}${pom.formattedDataTestId.includes("${") ? `(${args})` : ""}.ClickAsync();`);
611
+ }
612
+ else {
613
+ chunks.push(" Exception? lastError = null;");
614
+ chunks.push(` foreach (var testId in new[] { ${allTestIds.map(toCSharpTestIdExpression).join(", ")} })`);
615
+ chunks.push(" {");
616
+ chunks.push(" try");
617
+ chunks.push(" {");
618
+ chunks.push(" var locator = LocatorByTestId(testId);");
619
+ chunks.push(" if (await locator.CountAsync() > 0)");
620
+ chunks.push(" {");
621
+ chunks.push(" await locator.ClickAsync();");
622
+ chunks.push(` return new ${target}(Page);`);
623
+ chunks.push(" }");
624
+ chunks.push(" }");
625
+ chunks.push(" catch (Exception e)");
626
+ chunks.push(" {");
627
+ chunks.push(" lastError = e;");
628
+ chunks.push(" }");
629
+ chunks.push(" }");
630
+ chunks.push(" throw lastError ?? new System.Exception(\"[pom] Failed to navigate using any candidate test id.\");");
631
+ }
632
+ chunks.push(` return new ${target}(Page);`);
633
+ chunks.push(" }");
634
+ chunks.push("");
635
+ continue;
636
+ }
637
+
638
+ chunks.push(` public async Task ${actionName}(${sig})`);
639
+ chunks.push(" {");
640
+
641
+ const callSuffix = pom.formattedDataTestId.includes("${") ? `(${args})` : "";
642
+
643
+ const emitActionCall = (locatorAccess: string) => {
644
+ if (pom.nativeRole === "input") {
645
+ chunks.push(` await ${locatorAccess}.FillAsync(text);`);
646
+ }
647
+ else if (pom.nativeRole === "select") {
648
+ chunks.push(` await ${locatorAccess}.SelectOptionAsync(value);`);
649
+ }
650
+ else if (pom.nativeRole === "vselect") {
651
+ // vselect requires custom selection mechanics.
652
+ chunks.push(` await SelectVSelectByTestIdAsync(${testIdExpr}, value, timeOut);`);
653
+ }
654
+ else {
655
+ chunks.push(` await ${locatorAccess}.ClickAsync();`);
656
+ }
657
+ };
658
+
659
+ if (!pom.formattedDataTestId.includes("${") && allTestIds.length > 1) {
660
+ chunks.push(" Exception? lastError = null;");
661
+ chunks.push(` foreach (var testId in new[] { ${allTestIds.map(toCSharpTestIdExpression).join(", ")} })`);
662
+ chunks.push(" {");
663
+ chunks.push(" try");
664
+ chunks.push(" {");
665
+ if (pom.nativeRole === "vselect") {
666
+ chunks.push(" // vselect fallback: use the same selection routine for each candidate test id.");
667
+ chunks.push(" var root = LocatorByTestId(testId);");
668
+ chunks.push(" if (await root.CountAsync() > 0)");
669
+ chunks.push(" {");
670
+ chunks.push(" await SelectVSelectByTestIdAsync(testId, value, timeOut);");
671
+ chunks.push(" return;");
672
+ chunks.push(" }");
673
+ }
674
+ else {
675
+ chunks.push(" var locator = LocatorByTestId(testId);");
676
+ chunks.push(" if (await locator.CountAsync() > 0)");
677
+ chunks.push(" {");
678
+ if (pom.nativeRole === "input") {
679
+ chunks.push(" await locator.FillAsync(text);");
680
+ }
681
+ else if (pom.nativeRole === "select") {
682
+ chunks.push(" await locator.SelectOptionAsync(value);");
683
+ }
684
+ else {
685
+ chunks.push(" await locator.ClickAsync();");
686
+ }
687
+ chunks.push(" return;");
688
+ chunks.push(" }");
689
+ }
690
+ chunks.push(" }");
691
+ chunks.push(" catch (Exception e)");
692
+ chunks.push(" {");
693
+ chunks.push(" lastError = e;");
694
+ chunks.push(" }");
695
+ chunks.push(" }");
696
+ chunks.push(" throw lastError ?? new Exception(\"[pom] Failed to click any candidate test id.\");");
697
+ chunks.push(" }");
698
+ chunks.push("");
699
+ continue;
700
+ }
701
+
702
+ emitActionCall(`${locatorName}${callSuffix}`);
703
+
704
+ chunks.push(" }");
705
+ chunks.push("");
706
+ }
707
+
708
+ // Extra click specs
709
+ const extras = (deps.pomExtraMethods ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
710
+ for (const extra of extras) {
711
+ if (extra.kind !== "click")
712
+ continue;
713
+ const { signature } = formatCSharpParams(extra.params);
714
+ const needsTemplate = extra.formattedDataTestId.includes("${");
715
+ const testIdExpr = toCSharpTestIdExpression(extra.formattedDataTestId);
716
+
717
+ const extraName = upperFirst(extra.name);
718
+
719
+ chunks.push(` public async Task ${extraName}Async(${signature})`);
720
+ chunks.push(" {");
721
+ if (extra.keyLiteral !== undefined) {
722
+ chunks.push(` var key = ${JSON.stringify(extra.keyLiteral)};`);
723
+ }
724
+ if (needsTemplate) {
725
+ chunks.push(` var testId = ${testIdExpr};`);
726
+ chunks.push(" await LocatorByTestId(testId).ClickAsync();");
727
+ }
728
+ else {
729
+ chunks.push(` await LocatorByTestId(${testIdExpr}).ClickAsync();`);
730
+ }
731
+ chunks.push(" }");
732
+ chunks.push("");
733
+ }
734
+
735
+ chunks.push("}");
736
+ chunks.push("");
737
+ }
738
+
739
+ const outputFile = path.join(outAbs, "page-object-models.g.cs");
740
+ return [{ filePath: outputFile, content: chunks.join("\n") }];
741
+ }
742
+
743
+ function maybeGenerateFixtureRegistry(
744
+ componentHierarchyMap: Map<string, IComponentDependencies>,
745
+ options: {
746
+ generateFixtures: GenerateFilesOptions["generateFixtures"];
747
+ pomOutDir: string;
748
+ projectRoot?: string;
749
+ },
750
+ ) {
751
+ const { generateFixtures, pomOutDir, projectRoot } = options;
752
+ if (!generateFixtures)
753
+ return;
754
+
755
+ // generateFixtures accepts:
756
+ // - true: enable fixtures with defaults
757
+ // - "path": enable fixtures and write them under this directory OR to this file if it ends with .ts
758
+ // - { outDir }: enable fixtures and override output directory
759
+ const defaultFixtureOutDirRel = pomOutDir;
760
+ const fixtureOutRel = typeof generateFixtures === "string"
761
+ ? generateFixtures
762
+ : (typeof generateFixtures === "object" && generateFixtures?.outDir
763
+ ? generateFixtures.outDir
764
+ : defaultFixtureOutDirRel);
765
+
766
+ const looksLikeFilePath = fixtureOutRel.endsWith(".ts") || fixtureOutRel.endsWith(".tsx") || fixtureOutRel.endsWith(".mts") || fixtureOutRel.endsWith(".cts");
767
+ const fixtureOutDirRel = looksLikeFilePath ? path.dirname(fixtureOutRel) : fixtureOutRel;
768
+ const fixtureFileName = looksLikeFilePath ? path.basename(fixtureOutRel) : "fixtures.g.ts";
769
+
770
+ const root = projectRoot ?? process.cwd();
771
+ const fixtureOutDirAbs = path.isAbsolute(fixtureOutDirRel)
772
+ ? fixtureOutDirRel
773
+ : path.resolve(root, fixtureOutDirRel);
774
+
775
+ // Resolve the directory that contains the POM barrel export (e.g. <root>/pom).
776
+ const pomDirAbs = path.isAbsolute(pomOutDir) ? pomOutDir : path.resolve(root, pomOutDir);
777
+
778
+ const pomImport = toPosixRelativePath(fixtureOutDirAbs, pomDirAbs);
779
+
780
+ const viewClassNames = Array.from(componentHierarchyMap.entries())
781
+ .filter(([, deps]) => !!deps.isView)
782
+ .map(([name]) => name)
783
+ .sort((a, b) => a.localeCompare(b));
784
+
785
+ const reservedPlaywrightFixtureNames = new Set([
786
+ // Built-in Playwright fixtures
787
+ "page",
788
+ "context",
789
+ "browser",
790
+ "browserName",
791
+ "request",
792
+ // Our own fixtureOptions
793
+ "animation",
794
+ ]);
795
+
796
+ const viewFixtureNames = new Set(viewClassNames.map(name => lowerFirst(name)));
797
+
798
+ const componentClassNames = Array.from(componentHierarchyMap.entries())
799
+ .filter(([, deps]) => !deps.isView)
800
+ .map(([name]) => name)
801
+ .filter((name) => {
802
+ const fixtureName = lowerFirst(name);
803
+ if (reservedPlaywrightFixtureNames.has(fixtureName))
804
+ return false;
805
+ if (viewFixtureNames.has(fixtureName))
806
+ return false;
807
+ return true;
808
+ })
809
+ .sort((a, b) => a.localeCompare(b));
810
+
811
+ const header = `${eslintSuppressionHeader}/**\n`
812
+ + ` * DO NOT MODIFY BY HAND\n`
813
+ + ` *\n`
814
+ + ` * This file is auto-generated by vue-pom-generator.\n`
815
+ + ` * Changes should be made in the generator/template, not in the generated output.\n`
816
+ + ` */\n\n`;
817
+
818
+ // Concrete, strongly-typed fixtures for Playwright tests.
819
+ // test("...", async ({ preferencesPage }) => { ... })
820
+ //
821
+ // View POMs implement goTo() directly, so fixtures can be strongly typed without
822
+ // casting/augmenting at runtime.
823
+ const fixturesTypeEntries = viewClassNames
824
+ .map(name => ` ${lowerFirst(name)}: Pom.${name},`)
825
+ .join("\n");
826
+
827
+ const componentFixturesTypeEntries = componentClassNames
828
+ .map(name => ` ${lowerFirst(name)}: Pom.${name},`)
829
+ .join("\n");
830
+
831
+ const pomFactoryType = `export type PomConstructor<T> = new (page: PwPage) => T;\n\n`
832
+ + `export interface PomFactory {\n`
833
+ + ` create<T>(ctor: PomConstructor<T>): T;\n`
834
+ + `}\n\n`;
835
+
836
+ // NOTE: We intentionally do not generate "openXPage" helpers.
837
+ // Each view POM provides goTo(), and tests call it explicitly.
838
+
839
+ // Openers removed.
840
+
841
+ const fixturesContent = `${header
842
+ }/** Generated Playwright fixtures (typed page objects). */\n\n`
843
+ + `import { expect, test as base } from "@playwright/test";\n`
844
+ + `import type { Page as PwPage } from "@playwright/test";\n`
845
+ + `import * as Pom from "${pomImport}";\n\n`
846
+ + `export interface PlaywrightOptions {\n`
847
+ + ` animation: Pom.PlaywrightAnimationOptions;\n`
848
+ + `}\n\n`
849
+ + `${pomFactoryType}`
850
+ + `type PomSetupFixture = { pomSetup: void };\n`
851
+ + `type PomFactoryFixture = { pomFactory: PomFactory };\n\n`
852
+ + `const pageCtors = {\n${fixturesTypeEntries}\n} as const;\n`
853
+ + `const componentCtors = {\n${componentFixturesTypeEntries}\n} as const;\n\n`
854
+ + `export type GeneratedPageFixtures = { [K in keyof typeof pageCtors]: InstanceType<(typeof pageCtors)[K]> };\n`
855
+ + `export type GeneratedComponentFixtures = { [K in keyof typeof componentCtors]: InstanceType<(typeof componentCtors)[K]> };\n\n`
856
+ + `const makePomFixture = <T>(Ctor: PomConstructor<T>) => async ({ page }: { page: PwPage }, use: (t: T) => Promise<void>) => {\n`
857
+ + ` await use(new Ctor(page));\n`
858
+ + `};\n\n`
859
+ + `const createPomFixtures = <TMap extends Record<string, PomConstructor<any>>>(ctors: TMap) => {\n`
860
+ + ` const out: Record<string, any> = {};\n`
861
+ + ` for (const [key, Ctor] of Object.entries(ctors)) {\n`
862
+ + ` out[key] = makePomFixture(Ctor as PomConstructor<any>);\n`
863
+ + ` }\n`
864
+ + ` return out as any;\n`
865
+ + `};\n\n`
866
+ + `const test = base.extend<PlaywrightOptions & PomSetupFixture & PomFactoryFixture & GeneratedPageFixtures & GeneratedComponentFixtures>({\n`
867
+ + ` animation: [{\n`
868
+ + ` pointer: { durationMilliseconds: 250, transitionStyle: "ease-in-out", clickDelayMilliseconds: 0 },\n`
869
+ + ` keyboard: { typeDelayMilliseconds: 100 },\n`
870
+ + ` }, { option: true }],\n`
871
+ + ` pomSetup: [async ({ animation }, use) => {\n`
872
+ + ` Pom.setPlaywrightAnimationOptions(animation);\n`
873
+ + ` await use();\n`
874
+ + ` }, { auto: true }],\n`
875
+ + ` pomFactory: async ({ page }, use) => {\n`
876
+ + ` await use({\n`
877
+ + ` create: <T>(ctor: PomConstructor<T>) => new ctor(page),\n`
878
+ + ` });\n`
879
+ + ` },\n`
880
+ + ` ...createPomFixtures(pageCtors),\n`
881
+ + ` ...createPomFixtures(componentCtors),\n`
882
+ + `});\n\n`
883
+ + `export { test, expect };\n`;
884
+
885
+ createFile(path.resolve(fixtureOutDirAbs, fixtureFileName), fixturesContent);
886
+
887
+ // No pomFixture is generated; goToSelf is emitted directly on each view POM.
888
+ }
889
+
890
+ function generateViewObjectModelContent(
891
+ componentName: string,
892
+ dependencies: IComponentDependencies,
893
+ componentHierarchyMap: Map<string, IComponentDependencies>,
894
+ vueFilesPathMap: Map<string, string>,
895
+ basePageClassPath: string,
896
+ options: GenerateContentOptions = {},
897
+ ) {
898
+ const { isView, childrenComponentSet, usedComponentSet, filePath } = dependencies;
899
+
900
+ const {
901
+ outputDir = path.dirname(filePath),
902
+ aggregated = false,
903
+ customPomAttachments = [],
904
+ testIdAttribute,
905
+ } = options;
906
+
907
+ const hasChildComponent = (needle: string) => {
908
+ const haystack = usedComponentSet?.size ? usedComponentSet : childrenComponentSet;
909
+ for (const child of haystack) {
910
+ if (child === needle)
911
+ return true;
912
+ if (child === `${needle}.vue`)
913
+ return true;
914
+ if (child.endsWith(".vue") && child.slice(0, -4) === needle)
915
+ return true;
916
+ }
917
+ return false;
918
+ };
919
+
920
+ const attachmentsForThisClass = customPomAttachments
921
+ .filter((a) => {
922
+ const scope = a.attachTo ?? "views";
923
+ const scopeOk = isView
924
+ ? (scope === "views" || scope === "both")
925
+ : (scope === "components" || scope === "both");
926
+ if (!scopeOk)
927
+ return false;
928
+ return a.attachWhenUsesComponents.some(c => hasChildComponent(c));
929
+ })
930
+ .map(a => ({ className: a.className, propertyName: a.propertyName }));
931
+
932
+ let content: string = "";
933
+
934
+ const sourceRel = toPosixRelativePath(outputDir, filePath);
935
+ const kind = isView ? "Page" : "Component";
936
+ const doc = `/** ${kind} POM: ${componentName} (source: ${sourceRel}) */\n`;
937
+
938
+ // In aggregated mode, imports are hoisted once at the top of the file.
939
+ if (!aggregated) {
940
+ content = `${eslintSuppressionHeader}${doc}`;
941
+
942
+ // We only need PwPage when we emit a constructor (views always do; components only do
943
+ // when they have custom attachments like Grid).
944
+ if (isView || attachmentsForThisClass.length > 0) {
945
+ content += "import type { Page as PwPage } from \"@playwright/test\";\n";
946
+ }
947
+
948
+ const projectRoot = options.projectRoot ?? process.cwd();
949
+ const fromAbs = path.isAbsolute(outputDir) ? outputDir : path.resolve(projectRoot, outputDir);
950
+ const toAbs = basePageClassPath
951
+ ? (path.isAbsolute(basePageClassPath) ? basePageClassPath : path.resolve(projectRoot, basePageClassPath))
952
+ : "";
953
+ const basePageImport = path.relative(fromAbs, toAbs).replace(/\\/g, "/");
954
+ // stripExtension uses node:path formatting (platform-specific). Re-normalize to POSIX
955
+ // so the import specifier is valid on Windows.
956
+ const basePageImportNoExt = stripExtension(basePageImport).replace(/\\/g, "/");
957
+ const basePageImportSpecifier = basePageImportNoExt.startsWith(".") ? basePageImportNoExt : `./${basePageImportNoExt}`;
958
+ content += `import { BasePage, Fluent } from '${basePageImportSpecifier}';\n\n`;
959
+
960
+ if (isView && childrenComponentSet.size > 0) {
961
+ childrenComponentSet.forEach((child) => {
962
+ if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
963
+ const childPath = vueFilesPathMap.get(child);
964
+ let relativePath = path.relative(outputDir, childPath || "");
965
+ relativePath = changeExtension(relativePath, ".vue", ".g").replace(/\\/g, "/");
966
+ content += `import { ${child} } from '${relativePath}';\n`;
967
+ }
968
+ });
969
+ }
970
+ }
971
+ else {
972
+ // Keep per-class doc comment, but avoid repeating eslint suppression / imports.
973
+ content = doc;
974
+ }
975
+
976
+ content += `\nexport class ${componentName} extends BasePage {\n`;
977
+
978
+ const widgetInstances = isView
979
+ ? getWidgetInstancesForView(componentName, dependencies.dataTestIdSet)
980
+ : [];
981
+
982
+ // For views, `childrenComponentSet` only includes component tags on which we applied a data-testid.
983
+ // Thin wrapper views (e.g. NewTenantPage) may have *no* generated test ids but still contain
984
+ // important child component POMs (forms, grids, etc). In those cases, we use `usedComponentSet`
985
+ // to discover and instantiate child component POMs.
986
+ const componentRefsForInstances = isView
987
+ ? (usedComponentSet?.size ? usedComponentSet : childrenComponentSet)
988
+ : childrenComponentSet;
989
+
990
+ // Only views get child component instance fields by default.
991
+ // Components will only get a constructor/fields when they have explicit custom attachments
992
+ // (e.g. wrapper components around a third-party data grid should get a `grid: Grid`).
993
+ if (isView && (componentRefsForInstances.size > 0 || attachmentsForThisClass.length > 0 || widgetInstances.length > 0)) {
994
+ content += getComponentInstances(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances);
995
+ content += getConstructor(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances, { testIdAttribute });
996
+ }
997
+ if (!isView && attachmentsForThisClass.length > 0) {
998
+ content += getComponentInstances(new Set(), componentHierarchyMap, attachmentsForThisClass);
999
+ content += getConstructor(new Set(), componentHierarchyMap, attachmentsForThisClass, [], { testIdAttribute });
1000
+ }
1001
+
1002
+ // Ergonomics: when a view is primarily composed of a single component POM (e.g. a form),
1003
+ // allow calling that component's methods directly on the page class.
1004
+ //
1005
+ // Example:
1006
+ // await tenantListPage.goToNewTenant().typeTenantName(...).clickCreateTenant();
1007
+ //
1008
+ // Rules:
1009
+ // - Only for views (not components) to avoid polluting component surfaces.
1010
+ // - Only generate pass-throughs when the method is unambiguous across child components.
1011
+ // - Never generate a pass-through that would collide with an existing method on the view.
1012
+ // Only generate view passthrough methods when the view is essentially a thin wrapper
1013
+ // around a single child component POM. This prevents "layout" components (Page, PageHeader,
1014
+ // etc.) from injecting lots of noisy passthrough APIs into every view.
1015
+ if (isView && componentRefsForInstances.size === 1) {
1016
+ content += getViewPassthroughMethods(componentName, dependencies, componentRefsForInstances, componentHierarchyMap);
1017
+ }
1018
+
1019
+ if (isView && options.vueRouterFluentChaining) {
1020
+ const routeMeta = options.routeMetaByComponent?.[componentName] ?? null;
1021
+ content += generateRouteProperty(routeMeta);
1022
+ content += generateGoToSelfMethod(componentName);
1023
+ }
1024
+
1025
+ content += generateMethodsContentForDependencies(dependencies);
1026
+
1027
+ content += "}\n";
1028
+ return content;
1029
+ }
1030
+
1031
+ function getViewPassthroughMethods(
1032
+ viewName: string,
1033
+ viewDependencies: IComponentDependencies,
1034
+ childrenComponentSet: Set<string>,
1035
+ componentHierarchyMap: Map<string, IComponentDependencies>,
1036
+ ) {
1037
+ const existingOnView = viewDependencies.generatedMethods ?? new Map<string, { params: string; argNames: string[] } | null>();
1038
+
1039
+ // methodName -> candidates
1040
+ const methodToChildren = new Map<string, Array<{ childProp: string; params: string; argNames: string[] }>>();
1041
+
1042
+ for (const child of childrenComponentSet) {
1043
+ const childDeps = componentHierarchyMap.get(child);
1044
+ if (!childDeps || !childDeps.dataTestIdSet?.size)
1045
+ continue;
1046
+
1047
+ const methods = childDeps.generatedMethods;
1048
+ if (!methods)
1049
+ continue;
1050
+
1051
+ // Property name matches how we emit instance fields (strip .vue if present).
1052
+ const childProp = child.endsWith(".vue") ? child.slice(0, -4) : child;
1053
+
1054
+ for (const [name, sig] of methods.entries()) {
1055
+ if (!sig)
1056
+ continue; // ambiguous on the child itself
1057
+
1058
+ // If the view already has this method name, never generate a pass-through.
1059
+ if (existingOnView.has(name))
1060
+ continue;
1061
+
1062
+ const list = methodToChildren.get(name) ?? [];
1063
+ list.push({ childProp, params: sig.params, argNames: sig.argNames });
1064
+ methodToChildren.set(name, list);
1065
+ }
1066
+ }
1067
+
1068
+ const sorted = Array.from(methodToChildren.entries()).sort((a, b) => a[0].localeCompare(b[0]));
1069
+ const lines: string[] = [];
1070
+
1071
+ for (const [methodName, candidates] of sorted) {
1072
+ // Only generate when exactly one child can satisfy the call.
1073
+ if (candidates.length !== 1)
1074
+ continue;
1075
+
1076
+ const { childProp, params, argNames } = candidates[0];
1077
+ const callArgs = argNames.join(", ");
1078
+
1079
+ lines.push(
1080
+ "",
1081
+ ` async ${methodName}(${params}) {`,
1082
+ ` return await this.${childProp}.${methodName}(${callArgs});`,
1083
+ " }",
1084
+ );
1085
+ }
1086
+
1087
+ if (!lines.length) {
1088
+ return "";
1089
+ }
1090
+
1091
+ return [
1092
+ "",
1093
+ ` // Passthrough methods composed from child component POMs of ${viewName}.`,
1094
+ ...lines,
1095
+ "",
1096
+ ].join("\n");
1097
+ }
1098
+
1099
+ function ensureDir(dir: string) {
1100
+ const normalized = dir.replace(/\\/g, "/");
1101
+ if (!fs.existsSync(normalized)) {
1102
+ fs.mkdirSync(normalized, { recursive: true });
1103
+ }
1104
+ return normalized;
1105
+ }
1106
+
1107
+ async function generateAggregatedFiles(
1108
+ componentHierarchyMap: Map<string, IComponentDependencies>,
1109
+ vueFilesPathMap: Map<string, string>,
1110
+ basePageClassPath: string,
1111
+ outDir: string,
1112
+ options: {
1113
+ customPomAttachments?: GenerateFilesOptions["customPomAttachments"];
1114
+ projectRoot?: GenerateFilesOptions["projectRoot"];
1115
+ customPomDir?: GenerateFilesOptions["customPomDir"];
1116
+ customPomImportAliases?: GenerateFilesOptions["customPomImportAliases"];
1117
+ testIdAttribute?: GenerateFilesOptions["testIdAttribute"];
1118
+ generateFixtures?: GenerateFilesOptions["generateFixtures"];
1119
+ routeMetaByComponent?: Record<string, RouteMeta>;
1120
+ vueRouterFluentChaining?: boolean;
1121
+ } = {},
1122
+ ) {
1123
+ const projectRoot = options.projectRoot ?? process.cwd();
1124
+ const entries = Array.from(componentHierarchyMap.entries())
1125
+ .sort((a, b) => a[0].localeCompare(b[0]));
1126
+
1127
+ const views = entries.filter(([, d]) => d.isView);
1128
+ const components = entries.filter(([, d]) => !d.isView);
1129
+
1130
+ const makeAggregatedContent = (
1131
+ header: string,
1132
+ outputDir: string,
1133
+ items: Array<[string, IComponentDependencies]>,
1134
+ ) => {
1135
+ // Alias Playwright types to avoid collisions with generated classes (e.g. a Vue component named `Page`).
1136
+ const imports: string[] = ["import type { Locator as PwLocator, Page as PwPage } from \"@playwright/test\";"];
1137
+
1138
+ if (!basePageClassPath) {
1139
+ throw new Error("basePageClassPath is required for aggregated generation");
1140
+ }
1141
+
1142
+ // Inline BasePage into the aggregated output.
1143
+ //
1144
+ // Why:
1145
+ // - Playwright's runtime loader can treat workspace packages (like `vue-pom-generator`)
1146
+ // as external and not apply TS transforms/module resolution consistently.
1147
+ // - Importing a .ts file from inside a "type": "module" package can fail with
1148
+ // "Cannot find module" at runtime.
1149
+ //
1150
+ // Inlining keeps the generated POMs self-contained and stable across platforms.
1151
+ const clickInstrumentationInline = [
1152
+ "export const TESTID_CLICK_EVENT_NAME = \"__testid_event__\";",
1153
+ "export const TESTID_CLICK_EVENT_STRICT_FLAG = \"__testid_click_event_strict__\";",
1154
+ "export interface TestIdClickEventDetail {",
1155
+ " testId?: string;",
1156
+ " phase?: \"before\" | \"after\" | \"error\" | string;",
1157
+ " err?: string;",
1158
+ "}",
1159
+ ].join("\n");
1160
+
1161
+ const inlinePointerModule = () => {
1162
+ // Inline Pointer.ts from this package so generated POMs are self-contained and do not
1163
+ // rely on runtime TS module resolution within workspace packages.
1164
+ const pointerPath = fileURLToPath(new URL("./Pointer.ts", import.meta.url));
1165
+
1166
+ let pointerSource = "";
1167
+ try {
1168
+ pointerSource = fs.readFileSync(pointerPath, "utf8");
1169
+ }
1170
+ catch {
1171
+ throw new Error(`Failed to read Pointer.ts at ${pointerPath}`);
1172
+ }
1173
+
1174
+ // Replace the click-instrumentation import with an inline copy.
1175
+ pointerSource = pointerSource.replace(
1176
+ /import\s*\{[\s\S]*?\}\s*from\s*["']\.\.\/click-instrumentation["'];?\s*/,
1177
+ `${clickInstrumentationInline}\n\n`,
1178
+ );
1179
+
1180
+ // If Pointer uses a split value import + type-only import, remove the type-only import too.
1181
+ // The inline block already declares TestIdClickEventDetail.
1182
+ pointerSource = pointerSource.replace(
1183
+ /import\s+type\s*\{\s*TestIdClickEventDetail\s*\}\s*from\s*["']\.\.\/click-instrumentation["'];?\s*/g,
1184
+ "",
1185
+ );
1186
+
1187
+ // The aggregated file already imports these Playwright types once at the top.
1188
+ pointerSource = pointerSource.replace(
1189
+ /import\s+type\s*\{\s*Locator\s+as\s+PwLocator\s*,\s*Page\s+as\s+PwPage\s*\}\s*from\s*["']@playwright\/test["'];?\s*/,
1190
+ "",
1191
+ );
1192
+
1193
+ return pointerSource.trim();
1194
+ };
1195
+
1196
+ const inlineBasePageModule = () => {
1197
+ let basePageSource = "";
1198
+ try {
1199
+ basePageSource = fs.readFileSync(basePageClassPath, "utf8");
1200
+ }
1201
+ catch {
1202
+ throw new Error(`Failed to read BasePage.ts at ${basePageClassPath}`);
1203
+ }
1204
+
1205
+ // Replace the click-instrumentation import with an inline copy.
1206
+ basePageSource = basePageSource.replace(
1207
+ /import\s*\{[\s\S]*?\}\s*from\s*["']\.\.\/click-instrumentation["'];?\s*/,
1208
+ `${clickInstrumentationInline}\n\n`,
1209
+ );
1210
+
1211
+ // If BasePage uses a split value import + type-only import, remove the type-only import too.
1212
+ // The inline block already declares TestIdClickEventDetail.
1213
+ basePageSource = basePageSource.replace(
1214
+ /import\s+type\s*\{\s*TestIdClickEventDetail\s*\}\s*from\s*["']\.\.\/click-instrumentation["'];?\s*/g,
1215
+ "",
1216
+ );
1217
+
1218
+ // The aggregated file already imports these Playwright types once at the top.
1219
+ // Remove BasePage's own import to avoid duplicate identifiers.
1220
+ basePageSource = basePageSource.replace(
1221
+ /import\s+type\s*\{\s*Locator\s+as\s+PwLocator\s*,\s*Page\s+as\s+PwPage\s*\}\s*from\s*["']@playwright\/test["'];?\s*/,
1222
+ "",
1223
+ );
1224
+
1225
+ // BasePage references Pointer, but in aggregated output we inline Pointer above.
1226
+ basePageSource = basePageSource.replace(
1227
+ /import\s*\{\s*Pointer\s*\}\s*from\s*["']\.\/Pointer["'];?\s*/g,
1228
+ "",
1229
+ );
1230
+
1231
+ return basePageSource.trim();
1232
+ };
1233
+
1234
+ const pointerInline = inlinePointerModule();
1235
+ const basePageInline = inlineBasePageModule();
1236
+
1237
+ // Handwritten POM helpers for complicated/third-party widgets.
1238
+ // Convention: place them in `tests/playwright/pom/custom/*.ts`.
1239
+ // Import them rather than inlining so TypeScript can typecheck them.
1240
+ const addCustomPomImports = () => {
1241
+ // Some custom POM helpers intentionally share names with generated component POMs
1242
+ // (e.g. Toggle.vue -> generated class `Toggle`). Import with aliases to avoid
1243
+ // merged-declaration conflicts in the aggregated output.
1244
+ const importAliases: Record<string, string> = {
1245
+ Toggle: "ToggleWidget",
1246
+ Checkbox: "CheckboxWidget",
1247
+ ...(options.customPomImportAliases ?? {}),
1248
+ };
1249
+
1250
+ const customDirRelOrAbs = options.customPomDir ?? "tests/playwright/pom/custom";
1251
+ const customDirAbs = path.isAbsolute(customDirRelOrAbs)
1252
+ ? customDirRelOrAbs
1253
+ : path.resolve(projectRoot, customDirRelOrAbs);
1254
+
1255
+ if (!fs.existsSync(customDirAbs)) {
1256
+ return;
1257
+ }
1258
+
1259
+ const files = fs.readdirSync(customDirAbs)
1260
+ .filter(f => f.endsWith(".ts"))
1261
+ .sort((a, b) => a.localeCompare(b));
1262
+
1263
+ for (const file of files) {
1264
+ const exportName = file.replace(/\.ts$/i, "");
1265
+ // In this repo, custom POMs are authored as `export class <Name> { ... }`.
1266
+ // Import by the basename, which matches the class name convention.
1267
+ const alias = importAliases[exportName];
1268
+ const customFileAbs = path.join(customDirAbs, file);
1269
+ const fromOutputDir = outputDir;
1270
+ const importPath = stripExtension(toPosixRelativePath(fromOutputDir, customFileAbs));
1271
+ if (alias) {
1272
+ imports.push(`import { ${exportName} as ${alias} } from "${importPath}";`);
1273
+ }
1274
+ else {
1275
+ imports.push(`import { ${exportName} } from "${importPath}";`);
1276
+ }
1277
+ }
1278
+ };
1279
+
1280
+ addCustomPomImports();
1281
+
1282
+ // Collect any navigation return types referenced by generated methods so we can emit
1283
+ // stub classes when the destination view has no generated test ids (and therefore no
1284
+ // corresponding POM class in this file).
1285
+ const referencedTargets = new Set<string>();
1286
+ for (const [, deps] of items) {
1287
+ for (const dt of deps.dataTestIdSet) {
1288
+ if (dt.targetPageObjectModelClass) {
1289
+ referencedTargets.add(dt.targetPageObjectModelClass);
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ const generatedClassNames = new Set(items.map(([name]) => name));
1295
+ const stubTargets = Array.from(referencedTargets)
1296
+ .filter(t => !generatedClassNames.has(t))
1297
+ .sort((a, b) => a.localeCompare(b));
1298
+
1299
+ const availableClassNames = new Set<string>([...generatedClassNames, ...stubTargets]);
1300
+
1301
+ const depsByClassName = new Map<string, IComponentDependencies>(entries);
1302
+
1303
+ const scanPascalCaseTags = (template: string) => {
1304
+ // Extracts tag names like <TenantDetailsEditForm ...> without regex.
1305
+ // We only care about PascalCase component tags.
1306
+ const names: string[] = [];
1307
+ const len = template.length;
1308
+ let i = 0;
1309
+ while (i < len) {
1310
+ const ch = template[i];
1311
+ if (ch !== "<") {
1312
+ i++;
1313
+ continue;
1314
+ }
1315
+
1316
+ i++; // consume '<'
1317
+ if (i >= len)
1318
+ break;
1319
+
1320
+ // Skip closing tags and directives/comments
1321
+ if (template[i] === "/" || template[i] === "!" || template[i] === "?") {
1322
+ i++;
1323
+ continue;
1324
+ }
1325
+
1326
+ // Skip whitespace
1327
+ while (i < len && (template[i] === " " || template[i] === "\n" || template[i] === "\t" || template[i] === "\r")) i++;
1328
+ if (i >= len)
1329
+ break;
1330
+
1331
+ const first = template[i];
1332
+ // Only PascalCase (starts with A-Z)
1333
+ if (first < "A" || first > "Z") {
1334
+ continue;
1335
+ }
1336
+
1337
+ const start = i;
1338
+ i++;
1339
+ while (i < len) {
1340
+ const c = template[i];
1341
+ const isLetter = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z");
1342
+ const isDigit = c >= "0" && c <= "9";
1343
+ const isUnderscore = c === "_";
1344
+ if (isLetter || isDigit || isUnderscore) {
1345
+ i++;
1346
+ continue;
1347
+ }
1348
+ break;
1349
+ }
1350
+ const name = template.slice(start, i);
1351
+ if (name)
1352
+ names.push(name);
1353
+ }
1354
+ return Array.from(new Set(names));
1355
+ };
1356
+
1357
+ const getComposedStubBody = (targetClassName: string) => {
1358
+ const mapped = vueFilesPathMap.get(targetClassName);
1359
+ const candidates = [
1360
+ mapped,
1361
+ path.join(projectRoot, "src", "views", `${targetClassName}.vue`),
1362
+ path.join(projectRoot, "src", "components", `${targetClassName}.vue`),
1363
+ ].filter((p): p is string => typeof p === "string" && p.length > 0);
1364
+
1365
+ const filePath = candidates.find(p => fs.existsSync(p));
1366
+ if (!filePath)
1367
+ return undefined;
1368
+
1369
+ // Heuristic: scan the SFC template for PascalCase tags. If we have generated
1370
+ // POM classes for those components, include them as composed children.
1371
+ let source = "";
1372
+ try {
1373
+ source = fs.readFileSync(filePath, "utf8");
1374
+ }
1375
+ catch {
1376
+ return undefined;
1377
+ }
1378
+
1379
+ const templateOpen = source.indexOf("<template");
1380
+ const templateClose = source.lastIndexOf("</template>");
1381
+ if (templateOpen === -1 || templateClose === -1 || templateClose <= templateOpen)
1382
+ return undefined;
1383
+
1384
+ const afterOpenTag = source.indexOf(">", templateOpen);
1385
+ if (afterOpenTag === -1 || afterOpenTag >= templateClose)
1386
+ return undefined;
1387
+
1388
+ const template = source.slice(afterOpenTag + 1, templateClose);
1389
+ if (!template)
1390
+ return undefined;
1391
+
1392
+ const tags = scanPascalCaseTags(template);
1393
+ const childClassNames = Array.from(
1394
+ new Set(
1395
+ tags
1396
+ .filter(name => availableClassNames.has(name))
1397
+ .filter(name => name !== targetClassName),
1398
+ ),
1399
+ ).sort((a, b) => a.localeCompare(b));
1400
+
1401
+ if (!childClassNames.length)
1402
+ return undefined;
1403
+
1404
+ // Build passthrough methods from stub -> child component when the method is unambiguous.
1405
+ // This enables ergonomics like:
1406
+ // await tenantListPage.goToNewTenant().typeTenantName(...)
1407
+ // without forcing the test to reference `.TenantDetailsEditForm`.
1408
+ const methodToChildren = new Map<string, Array<{ child: string; params: string; argNames: string[] }>>();
1409
+ for (const child of childClassNames) {
1410
+ const childDeps = depsByClassName.get(child);
1411
+ const methods = childDeps?.generatedMethods;
1412
+ if (!methods)
1413
+ continue;
1414
+
1415
+ for (const [name, sig] of methods.entries()) {
1416
+ if (!sig)
1417
+ continue; // ambiguous
1418
+ const list = methodToChildren.get(name) ?? [];
1419
+ list.push({ child, params: sig.params, argNames: sig.argNames });
1420
+ methodToChildren.set(name, list);
1421
+ }
1422
+ }
1423
+
1424
+ const passthroughLines: string[] = [];
1425
+ for (const [methodName, candidatesForMethod] of methodToChildren.entries()) {
1426
+ if (candidatesForMethod.length !== 1)
1427
+ continue;
1428
+
1429
+ // Avoid creating pass-throughs for internal-ish helpers.
1430
+ if (methodName === "constructor")
1431
+ continue;
1432
+
1433
+ const { child, params, argNames } = candidatesForMethod[0];
1434
+ const callArgs = argNames.join(", ");
1435
+
1436
+ passthroughLines.push(
1437
+ "",
1438
+ ` async ${methodName}(${params}) {`,
1439
+ ` return await this.${child}.${methodName}(${callArgs});`,
1440
+ " }",
1441
+ );
1442
+ }
1443
+
1444
+ return {
1445
+ childClassNames,
1446
+ lines: [
1447
+ ...childClassNames.map(c => ` ${c}: ${c};`),
1448
+ "",
1449
+ " constructor(page: PwPage) {",
1450
+ " super(page);",
1451
+ ...childClassNames.map(c => ` this.${c} = new ${c}(page);`),
1452
+ " }",
1453
+ ...passthroughLines,
1454
+ ],
1455
+ };
1456
+ };
1457
+
1458
+ const stubs = stubTargets.map(t =>
1459
+ (() => {
1460
+ const composed = getComposedStubBody(t);
1461
+ const body = composed?.lines ?? [
1462
+ " constructor(page: PwPage) {",
1463
+ " super(page);",
1464
+ " }",
1465
+ ];
1466
+
1467
+ return [
1468
+ "/**\n * Stub POM generated because it is referenced as a navigation target but\n * did not have any generated test ids in this build.\n */",
1469
+ `export class ${t} extends BasePage {`,
1470
+ ...body,
1471
+ "}",
1472
+ ].join("\n");
1473
+ })(),
1474
+ );
1475
+
1476
+ const classes = items.map(([name, deps]) =>
1477
+ generateViewObjectModelContent(name, deps, componentHierarchyMap, vueFilesPathMap, basePageClassPath, {
1478
+ outputDir,
1479
+ aggregated: true,
1480
+
1481
+ customPomAttachments: options.customPomAttachments ?? [],
1482
+ testIdAttribute: options.testIdAttribute,
1483
+ vueRouterFluentChaining: options.vueRouterFluentChaining,
1484
+ routeMetaByComponent: options.routeMetaByComponent,
1485
+ }),
1486
+ );
1487
+
1488
+ const baseContent = [
1489
+ header,
1490
+ ...imports,
1491
+ "",
1492
+ pointerInline,
1493
+ "",
1494
+ basePageInline,
1495
+ "",
1496
+ ...classes,
1497
+ ...(stubs.length ? ["", ...stubs] : []),
1498
+ ].filter(Boolean).join("\n\n");
1499
+
1500
+ return baseContent;
1501
+ };
1502
+
1503
+ const base = ensureDir(outDir);
1504
+ const outputFile = path.join(base, "page-object-models.g.ts");
1505
+ const header = `${eslintSuppressionHeader}/**\n * Aggregated generated POMs\n${AUTO_GENERATED_COMMENT}`;
1506
+ const content = makeAggregatedContent(header, path.dirname(outputFile), [...views, ...components]);
1507
+
1508
+ const indexFile = path.join(base, "index.ts");
1509
+ const indexContent = `${eslintSuppressionHeader}/**\n * POM exports\n${AUTO_GENERATED_COMMENT}\n\nexport * from "./page-object-models.g";\n`;
1510
+
1511
+ return [
1512
+ { filePath: outputFile, content },
1513
+ { filePath: indexFile, content: indexContent },
1514
+ ];
1515
+ }
1516
+
1517
+ function createFile(filePath: string, content: string) {
1518
+ const dir = path.dirname(filePath);
1519
+ if (!fs.existsSync(dir)) {
1520
+ fs.mkdirSync(dir, { recursive: true });
1521
+ }
1522
+ if (fs.existsSync(filePath)) {
1523
+ fs.unlinkSync(filePath);
1524
+ }
1525
+ fs.writeFileSync(filePath, content);
1526
+ }
1527
+
1528
+ function lowerFirst(value: string): string {
1529
+ if (!value)
1530
+ return value;
1531
+ return value.charAt(0).toLowerCase() + value.slice(1);
1532
+ }
1533
+
1534
+ function toPascalCaseLocal(str: string): string {
1535
+ const cleaned = (str ?? "")
1536
+ .replace(/\$\{[^}]*\}/g, " ")
1537
+ .replace(/[^a-z0-9]+/gi, " ")
1538
+ .trim();
1539
+
1540
+ if (!cleaned)
1541
+ return "";
1542
+
1543
+ return cleaned
1544
+ .split(/\s+/)
1545
+ .filter(Boolean)
1546
+ .map((word) => {
1547
+ const preserveInternalCaps = /[a-z][A-Z]/.test(word);
1548
+ return preserveInternalCaps
1549
+ ? upperFirst(word)
1550
+ : upperFirst(word.toLowerCase());
1551
+ })
1552
+ .join("");
1553
+ }
1554
+
1555
+ interface WidgetInstance {
1556
+ className: "ToggleWidget" | "CheckboxWidget";
1557
+ propertyName: string;
1558
+ testId: string;
1559
+ }
1560
+
1561
+ function getWidgetInstancesForView(componentName: string, dataTestIdSet: Set<IDataTestId>): WidgetInstance[] {
1562
+ const out: WidgetInstance[] = [];
1563
+ const usedPropNames = new Set<string>();
1564
+
1565
+ const ensureUnique = (base: string) => {
1566
+ let candidate = base;
1567
+ let i = 2;
1568
+ while (usedPropNames.has(candidate)) {
1569
+ candidate = `${base}${i}`;
1570
+ i++;
1571
+ }
1572
+ usedPropNames.add(candidate);
1573
+ return candidate;
1574
+ };
1575
+
1576
+ for (const dt of dataTestIdSet) {
1577
+ const raw = dt.value;
1578
+
1579
+ // Skip keyed/dynamic test ids; instance fields can't represent those ergonomically.
1580
+ if (raw.includes("${")) {
1581
+ continue;
1582
+ }
1583
+
1584
+ const toggleSuffix = "-toggle";
1585
+ const checkboxSuffix = "-checkbox";
1586
+
1587
+ let className: WidgetInstance["className"] | null = null;
1588
+ let stem = "";
1589
+
1590
+ if (raw.endsWith(toggleSuffix)) {
1591
+ className = "ToggleWidget";
1592
+ stem = raw.slice(0, -toggleSuffix.length);
1593
+ }
1594
+ else if (raw.endsWith(checkboxSuffix)) {
1595
+ className = "CheckboxWidget";
1596
+ stem = raw.slice(0, -checkboxSuffix.length);
1597
+ }
1598
+ else {
1599
+ continue;
1600
+ }
1601
+
1602
+ // Prefer stripping the view prefix (e.g. PreferencesPage-) for cleaner member names.
1603
+ const viewPrefix = `${componentName}-`;
1604
+ const descriptorRaw = stem.startsWith(viewPrefix) ? stem.slice(viewPrefix.length) : stem;
1605
+ const descriptorPascal = toPascalCaseLocal(descriptorRaw);
1606
+
1607
+ if (!descriptorPascal) {
1608
+ continue;
1609
+ }
1610
+
1611
+ if (className === "ToggleWidget") {
1612
+ let base = descriptorPascal.replace(/Toggle$/i, "");
1613
+
1614
+ // Ergonomic naming: if a toggle name contains an "Enable..." tail, prefer that tail.
1615
+ // Example: AppPreferencesEnableSessionEmails -> enableSessionEmailsToggle
1616
+ const enableIndex = base.indexOf("Enable");
1617
+ if (enableIndex > 0) {
1618
+ base = base.slice(enableIndex);
1619
+ }
1620
+
1621
+ const propBase = lowerFirst(base);
1622
+ const propName = ensureUnique(propBase ? `${propBase}Toggle` : "toggle");
1623
+ out.push({ className, propertyName: propName, testId: raw });
1624
+ continue;
1625
+ }
1626
+
1627
+ // Checkbox
1628
+ const base = descriptorPascal
1629
+ .replace(/CheckBox$/i, "")
1630
+ .replace(/Checkbox$/i, "");
1631
+ const propBase = lowerFirst(base);
1632
+ const propName = ensureUnique(propBase ? `${propBase}Checkbox` : "checkbox");
1633
+ out.push({ className, propertyName: propName, testId: raw });
1634
+ }
1635
+
1636
+ return out;
1637
+ }
1638
+
1639
+ function getComponentInstances(
1640
+ childrenComponent: Set<string>,
1641
+ componentHierarchyMap: Map<string, IComponentDependencies>,
1642
+ attachmentsForThisView: Array<{ className: string; propertyName: string }> = [],
1643
+ widgetInstances: WidgetInstance[] = [],
1644
+ ) {
1645
+ let content = "\n";
1646
+
1647
+ for (const a of attachmentsForThisView) {
1648
+ content += ` ${a.propertyName}: ${a.className};\n`;
1649
+ }
1650
+
1651
+ for (const w of widgetInstances) {
1652
+ content += ` ${w.propertyName}: ${w.className};\n`;
1653
+ }
1654
+
1655
+ childrenComponent.forEach((child) => {
1656
+ if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
1657
+ const childName = child.split(".vue")[0];
1658
+ content += ` ${childName}: ${childName};\n`;
1659
+ }
1660
+ });
1661
+ return `${content}\n`;
1662
+ }
1663
+
1664
+ function getConstructor(
1665
+ childrenComponent: Set<string>,
1666
+ componentHierarchyMap: Map<string, IComponentDependencies>,
1667
+ attachmentsForThisView: Array<{ className: string; propertyName: string }> = [],
1668
+ widgetInstances: WidgetInstance[] = [],
1669
+ options?: { testIdAttribute?: string },
1670
+ ) {
1671
+ let content = " constructor(page: PwPage) {\n";
1672
+ const attr = (options?.testIdAttribute ?? "data-testid").trim() || "data-testid";
1673
+ content += ` super(page, { testIdAttribute: ${JSON.stringify(attr)} });\n`;
1674
+
1675
+ for (const a of attachmentsForThisView) {
1676
+ content += ` this.${a.propertyName} = new ${a.className}(page, this);\n`;
1677
+ }
1678
+
1679
+ for (const w of widgetInstances) {
1680
+ content += ` this.${w.propertyName} = new ${w.className}(page, ${JSON.stringify(w.testId)});\n`;
1681
+ }
1682
+
1683
+ childrenComponent.forEach((child) => {
1684
+ if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
1685
+ const childName = child.split(".vue")[0];
1686
+ content += ` this.${childName} = new ${childName}(page);\n`;
1687
+ }
1688
+ });
1689
+ content += " }";
1690
+ return `${content}\n`;
1691
+ }