@immense/vue-pom-generator 1.0.47 → 1.0.49

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.
Files changed (33) hide show
  1. package/README.md +30 -11
  2. package/RELEASE_NOTES.md +20 -33
  3. package/class-generation/Pointer.ts +69 -10
  4. package/class-generation/index.ts +1421 -682
  5. package/dist/class-generation/Pointer.d.ts +1 -1
  6. package/dist/class-generation/Pointer.d.ts.map +1 -1
  7. package/dist/class-generation/index.d.ts +8 -0
  8. package/dist/class-generation/index.d.ts.map +1 -1
  9. package/dist/index.cjs +1437 -689
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.mjs +1440 -692
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/manifest-generator.d.ts.map +1 -1
  14. package/dist/method-generation.d.ts +2 -0
  15. package/dist/method-generation.d.ts.map +1 -1
  16. package/dist/plugin/create-vue-pom-generator-plugins.d.ts.map +1 -1
  17. package/dist/plugin/support/build-plugin.d.ts +2 -1
  18. package/dist/plugin/support/build-plugin.d.ts.map +1 -1
  19. package/dist/plugin/support/dev-plugin.d.ts +2 -1
  20. package/dist/plugin/support/dev-plugin.d.ts.map +1 -1
  21. package/dist/plugin/support-plugins.d.ts +2 -1
  22. package/dist/plugin/support-plugins.d.ts.map +1 -1
  23. package/dist/plugin/types.d.ts +15 -7
  24. package/dist/plugin/types.d.ts.map +1 -1
  25. package/dist/tests/fixtures/generated-tsc/BasePage.full.d.ts +19 -0
  26. package/dist/tests/fixtures/generated-tsc/BasePage.full.d.ts.map +1 -0
  27. package/dist/tests/fixtures/generated-tsc/BasePage.minimal.d.ts +8 -0
  28. package/dist/tests/fixtures/generated-tsc/BasePage.minimal.d.ts.map +1 -0
  29. package/dist/tests/fixtures/generated-tsc/Pointer.d.ts +6 -0
  30. package/dist/tests/fixtures/generated-tsc/Pointer.d.ts.map +1 -0
  31. package/dist/typescript-codegen.d.ts +34 -0
  32. package/dist/typescript-codegen.d.ts.map +1 -0
  33. package/package.json +2 -1
@@ -1,12 +1,47 @@
1
1
  import { parse } from "@babel/parser";
2
2
  import type { ClassMethod } from "@babel/types";
3
+ import type { ElementNode, ForNode, IfBranchNode, IfNode, RootNode, TemplateChildNode } from "@vue/compiler-core";
4
+ import { ElementTypes } from "@vue/compiler-core";
5
+ import { NodeTypes, parse as parseTemplate } from "@vue/compiler-dom";
6
+ import { parse as parseSfc } from "@vue/compiler-sfc";
3
7
  import fs from "node:fs";
4
8
  import path from "node:path";
5
9
  import process from "node:process";
6
10
  import { fileURLToPath } from "node:url";
7
- import { generateViewObjectModelMethodContent } from "../method-generation";
11
+ import { generateViewObjectModelMembers, generateViewObjectModelMethodContent } from "../method-generation";
8
12
  import { introspectNuxtPages, parseRouterFileFromCwd } from "../router-introspection";
9
- import { IComponentDependencies, IDataTestId, PomExtraClickMethodSpec, PomPrimarySpec, upperFirst } from "../utils";
13
+ import {
14
+ addExportAll,
15
+ addNamedImport,
16
+ buildCommentBlock,
17
+ buildFilePrefix,
18
+ createClassConstructor,
19
+ createClassMethod,
20
+ createClassProperty,
21
+ renderClassMembers as renderTsMorphClassMembers,
22
+ renderSourceFile,
23
+ renderTypeScript,
24
+ StructureKind,
25
+ VariableDeclarationKind,
26
+ type ConstructorDeclarationStructure,
27
+ type GetAccessorDeclarationStructure,
28
+ type MethodDeclarationStructure,
29
+ type OptionalKind,
30
+ type ParameterDeclarationStructure,
31
+ type PropertyDeclarationStructure,
32
+ type TypeScriptClassMember,
33
+ type TypeScriptSourceFile,
34
+ type TypeScriptWriter,
35
+ writeCommentBlock,
36
+ } from "../typescript-codegen";
37
+ import {
38
+ IComponentDependencies,
39
+ IDataTestId,
40
+ PomExtraClickMethodSpec,
41
+ PomPrimarySpec,
42
+ toPascalCase,
43
+ upperFirst,
44
+ } from "../utils";
10
45
 
11
46
  // Intentionally imported so tooling understands this exported helper is part of the
12
47
  // generated POM public surface (it is consumed by generated Playwright fixtures).
@@ -16,15 +51,179 @@ void setPlaywrightAnimationOptions;
16
51
 
17
52
  export { generateViewObjectModelMethodContent };
18
53
 
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
54
  const GENERATED_GITATTRIBUTES_BLOCK_START = "# BEGIN vue-pom-generator generated files";
26
55
  const GENERATED_GITATTRIBUTES_BLOCK_END = "# END vue-pom-generator generated files";
27
56
  const eslintSuppressionHeader = "/* eslint-disable perfectionist/sort-imports */\n";
57
+ const VUE_POM_GENERATOR_ERROR_PREFIX = "[vue-pom-generator]" as const;
58
+
59
+ class VuePomGeneratorError extends Error {
60
+ public constructor(message: string) {
61
+ const normalized = message.startsWith(VUE_POM_GENERATOR_ERROR_PREFIX)
62
+ ? message
63
+ : `${VUE_POM_GENERATOR_ERROR_PREFIX} ${message}`;
64
+ super(normalized);
65
+ this.name = "VuePomGeneratorError";
66
+ }
67
+ }
68
+
69
+ function renderClassMembers(write: (writer: TypeScriptWriter) => void): string {
70
+ const content = renderTypeScript((writer) => {
71
+ writer.indent(() => {
72
+ write(writer);
73
+ });
74
+ });
75
+ return content.endsWith("\n") ? content : `${content}\n`;
76
+ }
77
+
78
+ function writeMemberBlock(writer: TypeScriptWriter, signature: string, body: (writer: TypeScriptWriter) => void): void {
79
+ writer.write(`${signature} `).block(() => {
80
+ body(writer);
81
+ });
82
+ }
83
+
84
+ function writeClassMembersText(writer: TypeScriptWriter, content: string): void {
85
+ if (!content) {
86
+ return;
87
+ }
88
+
89
+ const normalized = content
90
+ .replace(/\r\n/g, "\n")
91
+ .split("\n")
92
+ .map(line => line.startsWith(" ") ? line.slice(4) : line)
93
+ .join("\n");
94
+
95
+ writer.write(normalized.endsWith("\n") ? normalized : `${normalized}\n`);
96
+ }
97
+
98
+ function writeGeneratedMembers(writer: TypeScriptWriter, members: TypeScriptClassMember[]): void {
99
+ if (!members.length) {
100
+ return;
101
+ }
102
+
103
+ writeClassMembersText(writer, renderTsMorphClassMembers(members));
104
+ }
105
+
106
+ function splitParameterList(parameters: string): string[] {
107
+ const parts: string[] = [];
108
+ let current = "";
109
+ let braceDepth = 0;
110
+ let bracketDepth = 0;
111
+ let parenDepth = 0;
112
+ let angleDepth = 0;
113
+ let inSingleQuote = false;
114
+ let inDoubleQuote = false;
115
+ let inTemplateString = false;
116
+
117
+ for (let index = 0; index < parameters.length; index += 1) {
118
+ const char = parameters[index];
119
+ const previous = index > 0 ? parameters[index - 1] : "";
120
+
121
+ if (char === "'" && !inDoubleQuote && !inTemplateString && previous !== "\\") {
122
+ inSingleQuote = !inSingleQuote;
123
+ current += char;
124
+ continue;
125
+ }
126
+ if (char === "\"" && !inSingleQuote && !inTemplateString && previous !== "\\") {
127
+ inDoubleQuote = !inDoubleQuote;
128
+ current += char;
129
+ continue;
130
+ }
131
+ if (char === "`" && !inSingleQuote && !inDoubleQuote && previous !== "\\") {
132
+ inTemplateString = !inTemplateString;
133
+ current += char;
134
+ continue;
135
+ }
136
+
137
+ if (inSingleQuote || inDoubleQuote || inTemplateString) {
138
+ current += char;
139
+ continue;
140
+ }
141
+
142
+ switch (char) {
143
+ case "{":
144
+ braceDepth += 1;
145
+ break;
146
+ case "}":
147
+ braceDepth -= 1;
148
+ break;
149
+ case "[":
150
+ bracketDepth += 1;
151
+ break;
152
+ case "]":
153
+ bracketDepth -= 1;
154
+ break;
155
+ case "(":
156
+ parenDepth += 1;
157
+ break;
158
+ case ")":
159
+ parenDepth -= 1;
160
+ break;
161
+ case "<":
162
+ angleDepth += 1;
163
+ break;
164
+ case ">":
165
+ angleDepth -= 1;
166
+ break;
167
+ case ",":
168
+ if (braceDepth === 0 && bracketDepth === 0 && parenDepth === 0 && angleDepth === 0) {
169
+ const trimmed = current.trim();
170
+ if (trimmed) {
171
+ parts.push(trimmed);
172
+ }
173
+ current = "";
174
+ continue;
175
+ }
176
+ break;
177
+ default:
178
+ break;
179
+ }
180
+
181
+ current += char;
182
+ }
183
+
184
+ const trimmed = current.trim();
185
+ if (trimmed) {
186
+ parts.push(trimmed);
187
+ }
188
+
189
+ return parts;
190
+ }
191
+
192
+ function parseParameterSignature(parameter: string): OptionalKind<ParameterDeclarationStructure> {
193
+ const colonIndex = parameter.indexOf(":");
194
+ if (colonIndex < 0) {
195
+ return { name: parameter.trim() };
196
+ }
197
+
198
+ const rawName = parameter.slice(0, colonIndex).trim();
199
+ const hasQuestionToken = rawName.endsWith("?");
200
+ const name = hasQuestionToken ? rawName.slice(0, -1).trim() : rawName;
201
+ const remainder = parameter.slice(colonIndex + 1).trim();
202
+ const initializerIndex = remainder.lastIndexOf("=");
203
+
204
+ if (initializerIndex < 0) {
205
+ return {
206
+ name,
207
+ hasQuestionToken,
208
+ type: remainder || undefined,
209
+ };
210
+ }
211
+
212
+ return {
213
+ name,
214
+ hasQuestionToken,
215
+ type: remainder.slice(0, initializerIndex).trim() || undefined,
216
+ initializer: remainder.slice(initializerIndex + 1).trim() || undefined,
217
+ };
218
+ }
219
+
220
+ function parseParameterSignatures(parameters: string): OptionalKind<ParameterDeclarationStructure>[] {
221
+ const trimmed = parameters.trim();
222
+ if (!trimmed) {
223
+ return [];
224
+ }
225
+ return splitParameterList(trimmed).map(parseParameterSignature);
226
+ }
28
227
 
29
228
  function toPosixRelativePath(fromDir: string, toFile: string): string {
30
229
  let rel = path.relative(fromDir, toFile).replace(/\\/g, "/");
@@ -34,13 +233,6 @@ function toPosixRelativePath(fromDir: string, toFile: string): string {
34
233
  return rel;
35
234
  }
36
235
 
37
- function changeExtension(filePath: string, expectedExt: string, nextExtWithDot: string): string {
38
- const parsed = path.parse(filePath);
39
- if (parsed.ext !== expectedExt)
40
- return filePath;
41
- return path.format({ ...parsed, base: `${parsed.name}${nextExtWithDot}`, ext: nextExtWithDot });
42
- }
43
-
44
236
  function stripExtension(filePath: string): string {
45
237
  // IMPORTANT:
46
238
  // This helper is used for generating *import specifiers*.
@@ -86,6 +278,101 @@ interface ResolvedCustomPomAttachment {
86
278
  methodSignatures: CustomPomMethodSignatureMap;
87
279
  }
88
280
 
281
+ export type TypeScriptOutputStructure = "aggregated" | "split";
282
+
283
+ interface ResolvedCustomPomImportSpecifier {
284
+ exportName: string;
285
+ localIdentifier: string;
286
+ absolutePath: string;
287
+ }
288
+
289
+ interface CustomPomImportResolution {
290
+ classIdentifierMap: Record<string, string>;
291
+ methodSignaturesByClass: Map<string, CustomPomMethodSignatureMap>;
292
+ availableClassIdentifiers: Set<string>;
293
+ importSpecifiersByClass: Record<string, ResolvedCustomPomImportSpecifier>;
294
+ }
295
+
296
+ function createCustomPomImportCollisionError(exportName: string, requested: string): VuePomGeneratorError {
297
+ return new VuePomGeneratorError(
298
+ `Custom POM import name collision detected for "${exportName}".\n`
299
+ + `The identifier "${requested}" conflicts with a generated POM class.\n`
300
+ + `Fix by setting generation.playwright.customPoms.importAliases["${exportName}"] to a unique name, `
301
+ + `or set generation.playwright.customPoms.importNameCollisionBehavior = "alias" to auto-alias collisions.`,
302
+ );
303
+ }
304
+
305
+ function normalizeComponentTagToClassName(tag: string): string | undefined {
306
+ // Vue templates may reference the same component as <MyWidget /> or <my-widget />.
307
+ const className = toPascalCase(tag);
308
+ return className || undefined;
309
+ }
310
+
311
+ function collectReferencedComponentClassNames(nodes: readonly TemplateChildNode[], names: Set<string>): void {
312
+ for (const node of nodes) {
313
+ switch (node.type) {
314
+ case NodeTypes.ELEMENT: {
315
+ const element = node as ElementNode;
316
+ if (element.tagType === ElementTypes.COMPONENT) {
317
+ const className = normalizeComponentTagToClassName(element.tag);
318
+ if (className) {
319
+ names.add(className);
320
+ }
321
+ }
322
+ collectReferencedComponentClassNames(element.children, names);
323
+ break;
324
+ }
325
+ case NodeTypes.IF: {
326
+ const ifNode = node as IfNode;
327
+ for (const branch of ifNode.branches) {
328
+ collectReferencedComponentClassNames((branch as IfBranchNode).children, names);
329
+ }
330
+ break;
331
+ }
332
+ case NodeTypes.FOR: {
333
+ const forNode = node as ForNode;
334
+ collectReferencedComponentClassNames(forNode.children, names);
335
+ break;
336
+ }
337
+ default:
338
+ break;
339
+ }
340
+ }
341
+ }
342
+
343
+ function getComponentClassNamesFromVueSource(source: string): string[] {
344
+ try {
345
+ const { descriptor } = parseSfc(source);
346
+ const template = descriptor.template?.content?.trim();
347
+ if (!template) {
348
+ return [];
349
+ }
350
+
351
+ const root = parseTemplate(template) as RootNode;
352
+ const names = new Set<string>();
353
+ collectReferencedComponentClassNames(root.children, names);
354
+ return [...names];
355
+ }
356
+ catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ function resolveVueSourcePath(
362
+ targetClassName: string,
363
+ vueFilesPathMap: Map<string, string>,
364
+ projectRoot: string,
365
+ ): string | undefined {
366
+ const mapped = vueFilesPathMap.get(targetClassName);
367
+ const candidates = [
368
+ mapped,
369
+ path.join(projectRoot, "src", "views", `${targetClassName}.vue`),
370
+ path.join(projectRoot, "src", "components", `${targetClassName}.vue`),
371
+ ].filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0);
372
+
373
+ return candidates.find(candidate => fs.existsSync(candidate));
374
+ }
375
+
89
376
  async function getRouteMetaByComponent(
90
377
  projectRoot?: string,
91
378
  routerEntry?: string,
@@ -138,38 +425,44 @@ async function getRouteMetaByComponent(
138
425
  );
139
426
  }
140
427
 
141
- function generateRouteProperty(routeMeta: RouteMeta | null): string {
142
- if (!routeMeta) {
143
- return " static readonly route: { template: string } | null = null;\n";
144
- }
145
-
428
+ function generateRouteProperty(routeMeta: RouteMeta | null): TypeScriptClassMember[] {
146
429
  return [
147
- " static readonly route: { template: string } | null = {",
148
- ` template: ${JSON.stringify(routeMeta.template)},`,
149
- " } as const;",
150
- "",
151
- ].join("\n");
430
+ createClassProperty({
431
+ name: "route",
432
+ isStatic: true,
433
+ isReadonly: true,
434
+ type: "{ template: string } | null",
435
+ initializer: routeMeta
436
+ ? `{ template: ${JSON.stringify(routeMeta.template)} } as const`
437
+ : "null",
438
+ }),
439
+ ];
152
440
  }
153
441
 
154
- function generateGoToSelfMethod(componentName: string): string {
442
+ function generateGoToSelfMethod(componentName: string): TypeScriptClassMember[] {
155
443
  return [
156
- "",
157
- " async goTo() {",
158
- " await this.goToSelf();",
159
- " }",
160
- "",
161
- " async goToSelf() {",
162
- ` const route = ${componentName}.route;`,
163
- " if (!route) {",
164
- ` throw new Error("[pom] No router path found for component/page-object '${componentName}'.");`,
165
- " }",
166
- " const runtimeEnv = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;",
167
- " const runtimeBaseUrl = runtimeEnv?.PLAYWRIGHT_RUNTIME_BASE_URL ?? runtimeEnv?.PLAYWRIGHT_TEST_BASE_URL ?? runtimeEnv?.VITE_PLAYWRIGHT_BASE_URL;",
168
- " const targetUrl = runtimeBaseUrl ? new URL(route.template, runtimeBaseUrl).toString() : route.template;",
169
- " await this.page.goto(targetUrl);",
170
- " }",
171
- "",
172
- ].join("\n");
444
+ createClassMethod({
445
+ name: "goTo",
446
+ isAsync: true,
447
+ statements: [
448
+ "await this.goToSelf();",
449
+ ],
450
+ }),
451
+ createClassMethod({
452
+ name: "goToSelf",
453
+ isAsync: true,
454
+ statements: [
455
+ `const route = ${componentName}.route;`,
456
+ "if (!route) {",
457
+ ` throw new Error("[pom] No router path found for component/page-object '${componentName}'.");`,
458
+ "}",
459
+ "const runtimeEnv = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;",
460
+ "const runtimeBaseUrl = runtimeEnv?.PLAYWRIGHT_RUNTIME_BASE_URL ?? runtimeEnv?.PLAYWRIGHT_TEST_BASE_URL ?? runtimeEnv?.VITE_PLAYWRIGHT_BASE_URL;",
461
+ "const targetUrl = runtimeBaseUrl ? new URL(route.template, runtimeBaseUrl).toString() : route.template;",
462
+ "await this.page.goto(targetUrl);",
463
+ ],
464
+ }),
465
+ ];
173
466
  }
174
467
 
175
468
  function formatMethodParams(params: Record<string, string> | undefined): string {
@@ -195,24 +488,14 @@ function formatMethodParams(params: Record<string, string> | undefined): string
195
488
  .join(", ");
196
489
  }
197
490
 
198
- function generateExtraClickMethodContent(spec: PomExtraClickMethodSpec): string {
491
+ function generateExtraClickMethodMembers(spec: PomExtraClickMethodSpec): TypeScriptClassMember[] {
199
492
  if (spec.kind !== "click") {
200
- return "";
493
+ return [];
201
494
  }
202
495
 
203
496
  const params = spec.params ?? {};
204
497
  const signatureParams = formatMethodParams(params);
205
- const signature = signatureParams ? `(${signatureParams})` : "()";
206
-
207
- const lines: string[] = [];
208
- lines.push(
209
- "",
210
- ` async ${spec.name}${signature} {`,
211
- );
212
-
213
- if (spec.keyLiteral !== undefined) {
214
- lines.push(` const key = ${JSON.stringify(spec.keyLiteral)};`);
215
- }
498
+ const parameters = parseParameterSignatures(signatureParams);
216
499
 
217
500
  const hasAnnotationText = Object.prototype.hasOwnProperty.call(params, "annotationText");
218
501
  const hasWait = Object.prototype.hasOwnProperty.call(params, "wait");
@@ -226,7 +509,7 @@ function generateExtraClickMethodContent(spec: PomExtraClickMethodSpec): string
226
509
  : JSON.stringify(spec.selector.formattedDataTestId);
227
510
 
228
511
  if (needsTemplate) {
229
- lines.push(` const testId = ${testIdExpr};`);
512
+ // handled below
230
513
  }
231
514
 
232
515
  const clickArgs: string[] = [];
@@ -239,10 +522,22 @@ function generateExtraClickMethodContent(spec: PomExtraClickMethodSpec): string
239
522
  clickArgs.push(waitArg);
240
523
  }
241
524
 
242
- lines.push(` await this.clickByTestId(${clickArgs.join(", ")});`);
243
- lines.push(" }");
244
-
245
- return `${lines.join("\n")}\n`;
525
+ return [
526
+ createClassMethod({
527
+ name: spec.name,
528
+ isAsync: true,
529
+ parameters,
530
+ statements: (writer) => {
531
+ if (spec.keyLiteral !== undefined) {
532
+ writer.writeLine(`const key = ${JSON.stringify(spec.keyLiteral)};`);
533
+ }
534
+ if (needsTemplate) {
535
+ writer.writeLine(`const testId = ${testIdExpr};`);
536
+ }
537
+ writer.writeLine(`await this.clickByTestId(${clickArgs.join(", ")});`);
538
+ },
539
+ }),
540
+ ];
246
541
  }
247
542
 
248
543
  const rootNeedsTemplate = spec.selector.rootFormattedDataTestId.includes("${");
@@ -254,26 +549,35 @@ function generateExtraClickMethodContent(spec: PomExtraClickMethodSpec): string
254
549
  ? `\`${spec.selector.formattedLabel}\``
255
550
  : JSON.stringify(spec.selector.formattedLabel);
256
551
 
257
- if (rootNeedsTemplate) {
258
- lines.push(` const rootTestId = ${rootExpr};`);
259
- }
260
- if (labelNeedsTemplate) {
261
- lines.push(` const label = ${labelExpr};`);
262
- }
263
552
  const rootArg = rootNeedsTemplate ? "rootTestId" : rootExpr;
264
553
  const labelArg = labelNeedsTemplate ? "label" : labelExpr;
265
- lines.push(` await this.clickWithinTestIdByLabel(${rootArg}, ${labelArg}, ${annotationArg}, ${waitArg});`);
266
- lines.push(" }");
267
-
268
- return `${lines.join("\n")}\n`;
554
+ return [
555
+ createClassMethod({
556
+ name: spec.name,
557
+ isAsync: true,
558
+ parameters,
559
+ statements: (writer) => {
560
+ if (spec.keyLiteral !== undefined) {
561
+ writer.writeLine(`const key = ${JSON.stringify(spec.keyLiteral)};`);
562
+ }
563
+ if (rootNeedsTemplate) {
564
+ writer.writeLine(`const rootTestId = ${rootExpr};`);
565
+ }
566
+ if (labelNeedsTemplate) {
567
+ writer.writeLine(`const label = ${labelExpr};`);
568
+ }
569
+ writer.writeLine(`await this.clickWithinTestIdByLabel(${rootArg}, ${labelArg}, ${annotationArg}, ${waitArg});`);
570
+ },
571
+ }),
572
+ ];
269
573
  }
270
574
 
271
- function generateMethodContentFromPom(primary: PomPrimarySpec, targetPageObjectModelClass?: string): string {
575
+ function generateMethodMembersFromPom(primary: PomPrimarySpec, targetPageObjectModelClass?: string): TypeScriptClassMember[] {
272
576
  if (primary.emitPrimary === false) {
273
- return "";
577
+ return [];
274
578
  }
275
579
 
276
- return generateViewObjectModelMethodContent(
580
+ return generateViewObjectModelMembers(
277
581
  targetPageObjectModelClass,
278
582
  primary.methodName,
279
583
  primary.nativeRole,
@@ -284,7 +588,7 @@ function generateMethodContentFromPom(primary: PomPrimarySpec, targetPageObjectM
284
588
  );
285
589
  }
286
590
 
287
- function generateMethodsContentForDependencies(dependencies: IComponentDependencies): string {
591
+ function generateMethodsContentForDependencies(dependencies: IComponentDependencies): TypeScriptClassMember[] {
288
592
  const entries = Array.from(dependencies.dataTestIdSet ?? []);
289
593
  const primarySpecsAll = entries
290
594
  .map(e => ({ pom: e.pom, target: e.targetPageObjectModelClass }))
@@ -322,16 +626,16 @@ function generateMethodsContentForDependencies(dependencies: IComponentDependenc
322
626
  .slice()
323
627
  .sort((a, b) => a.name.localeCompare(b.name));
324
628
 
325
- let content = "";
629
+ const members: TypeScriptClassMember[] = [];
326
630
  for (const { pom, target } of primarySpecs) {
327
- content += generateMethodContentFromPom(pom, target);
631
+ members.push(...generateMethodMembersFromPom(pom, target));
328
632
  }
329
633
 
330
634
  for (const extra of extras) {
331
- content += generateExtraClickMethodContent(extra);
635
+ members.push(...generateExtraClickMethodMembers(extra));
332
636
  }
333
637
 
334
- return content;
638
+ return members;
335
639
  }
336
640
 
337
641
  export interface GenerateFilesOptions {
@@ -403,6 +707,14 @@ export interface GenerateFilesOptions {
403
707
  /** Which POM languages to emit. Defaults to ["ts"]. */
404
708
  emitLanguages?: Array<"ts" | "csharp">;
405
709
 
710
+ /**
711
+ * Controls how TypeScript Playwright page objects are emitted.
712
+ *
713
+ * - "aggregated" (default): emit a single `page-object-models.g.ts`
714
+ * - "split": emit one generated `.g.ts` file per class plus a stable `index.ts` barrel
715
+ */
716
+ typescriptOutputStructure?: TypeScriptOutputStructure;
717
+
406
718
  /** C# generation options. */
407
719
  csharp?: {
408
720
  namespace?: string;
@@ -423,11 +735,9 @@ export interface GenerateFilesOptions {
423
735
  routeMetaByComponent?: Record<string, RouteMeta>;
424
736
  }
425
737
 
426
- interface GenerateContentOptions {
738
+ interface BaseGenerateContentOptions {
427
739
  /** Directory the generated .g.ts file will live in (used for relative imports). Defaults to the Vue file's directory. */
428
740
  outputDir?: string;
429
- /** When true, omit file headers/import blocks that should be shared in an aggregated file. */
430
- aggregated?: boolean;
431
741
 
432
742
  customPomAttachments?: CustomPomAttachment[];
433
743
 
@@ -436,7 +746,9 @@ interface GenerateContentOptions {
436
746
  customPomImportAliases?: Record<string, string>;
437
747
  customPomClassIdentifierMap?: Record<string, string>;
438
748
  customPomAvailableClassIdentifiers?: Set<string>;
749
+ customPomImportSpecifiersByClass?: Record<string, ResolvedCustomPomImportSpecifier>;
439
750
  customPomMethodSignaturesByClass?: Map<string, CustomPomMethodSignatureMap>;
751
+ generatedTsFilePathByComponent?: Map<string, string>;
440
752
 
441
753
  /** Attribute name to treat as the test id. Defaults to `data-testid`. */
442
754
  testIdAttribute?: string;
@@ -447,6 +759,12 @@ interface GenerateContentOptions {
447
759
  routeMetaByComponent?: Record<string, RouteMeta>;
448
760
  }
449
761
 
762
+ type GenerateContentOptions
763
+ = BaseGenerateContentOptions & (
764
+ { outputStructure: "aggregated" }
765
+ | { outputStructure?: "split" }
766
+ );
767
+
450
768
  interface GeneratedFileOutput {
451
769
  filePath: string;
452
770
  content: string;
@@ -468,6 +786,7 @@ export async function generateFiles(
468
786
  customPomImportNameCollisionBehavior = "error",
469
787
  testIdAttribute,
470
788
  emitLanguages: emitLanguagesOverride,
789
+ typescriptOutputStructure = "aggregated",
471
790
  csharp,
472
791
  vueRouterFluentChaining,
473
792
  routerEntry,
@@ -498,17 +817,28 @@ export async function generateFiles(
498
817
  };
499
818
 
500
819
  if (emitLanguages.includes("ts")) {
501
- const files = await generateAggregatedFiles(componentHierarchyMap, vueFilesPathMap, basePageClassPath, outDir, {
502
- customPomAttachments,
503
- projectRoot,
504
- customPomDir,
505
- customPomImportAliases,
506
- customPomImportNameCollisionBehavior,
507
- testIdAttribute,
508
- generateFixtures,
509
- routeMetaByComponent,
510
- vueRouterFluentChaining,
511
- });
820
+ const files = typescriptOutputStructure === "split"
821
+ ? await generateSplitTypeScriptFiles(componentHierarchyMap, vueFilesPathMap, basePageClassPath, outDir, {
822
+ customPomAttachments,
823
+ projectRoot,
824
+ customPomDir,
825
+ customPomImportAliases,
826
+ customPomImportNameCollisionBehavior,
827
+ testIdAttribute,
828
+ routeMetaByComponent,
829
+ vueRouterFluentChaining,
830
+ })
831
+ : await generateAggregatedFiles(componentHierarchyMap, vueFilesPathMap, basePageClassPath, outDir, {
832
+ customPomAttachments,
833
+ projectRoot,
834
+ customPomDir,
835
+ customPomImportAliases,
836
+ customPomImportNameCollisionBehavior,
837
+ testIdAttribute,
838
+ generateFixtures,
839
+ routeMetaByComponent,
840
+ vueRouterFluentChaining,
841
+ });
512
842
  for (const file of files) {
513
843
  writeGeneratedFile(file);
514
844
  }
@@ -541,6 +871,133 @@ export async function generateFiles(
541
871
  }
542
872
  }
543
873
 
874
+ async function generateSplitTypeScriptFiles(
875
+ componentHierarchyMap: Map<string, IComponentDependencies>,
876
+ vueFilesPathMap: Map<string, string>,
877
+ basePageClassPath: string,
878
+ outDir: string,
879
+ options: {
880
+ customPomAttachments?: GenerateFilesOptions["customPomAttachments"];
881
+ projectRoot?: GenerateFilesOptions["projectRoot"];
882
+ customPomDir?: GenerateFilesOptions["customPomDir"];
883
+ customPomImportAliases?: GenerateFilesOptions["customPomImportAliases"];
884
+ customPomImportNameCollisionBehavior?: GenerateFilesOptions["customPomImportNameCollisionBehavior"];
885
+ testIdAttribute?: GenerateFilesOptions["testIdAttribute"];
886
+ routeMetaByComponent?: Record<string, RouteMeta>;
887
+ vueRouterFluentChaining?: boolean;
888
+ } = {},
889
+ ): Promise<GeneratedFileOutput[]> {
890
+ const projectRoot = options.projectRoot ?? process.cwd();
891
+ const entries = Array.from(componentHierarchyMap.entries())
892
+ .sort((a, b) => a[0].localeCompare(b[0]));
893
+
894
+ const base = ensureDir(outDir);
895
+ const generatedClassNames = new Set(entries.map(([name]) => name));
896
+ const referencedTargets = new Set<string>();
897
+ for (const [, deps] of entries) {
898
+ for (const dataTestId of deps.dataTestIdSet ?? []) {
899
+ if (dataTestId.targetPageObjectModelClass) {
900
+ referencedTargets.add(dataTestId.targetPageObjectModelClass);
901
+ }
902
+ }
903
+ }
904
+
905
+ const stubTargets = Array.from(referencedTargets)
906
+ .filter(target => !generatedClassNames.has(target))
907
+ .sort((a, b) => a.localeCompare(b));
908
+
909
+ const availableClassNames = new Set<string>([...generatedClassNames, ...stubTargets]);
910
+ const depsByClassName = new Map<string, IComponentDependencies>(entries);
911
+ const generatedTsFilePathByComponent = new Map<string, string>();
912
+ for (const className of availableClassNames) {
913
+ generatedTsFilePathByComponent.set(className, path.join(base, `${className}.g.ts`));
914
+ }
915
+
916
+ const customPomImportResolution = resolveCustomPomImportResolution(generatedClassNames, projectRoot, {
917
+ customPomDir: options.customPomDir,
918
+ customPomImportAliases: options.customPomImportAliases,
919
+ customPomImportNameCollisionBehavior: options.customPomImportNameCollisionBehavior,
920
+ });
921
+
922
+ const runtimeBasePagePath = path.join(base, "_pom-runtime", "class-generation", "BasePage.ts");
923
+ const files: GeneratedFileOutput[] = [];
924
+
925
+ for (const [name, deps] of entries) {
926
+ const filePath = generatedTsFilePathByComponent.get(name);
927
+ if (!filePath) {
928
+ continue;
929
+ }
930
+
931
+ const content = generateViewObjectModelContent(name, deps, componentHierarchyMap, vueFilesPathMap, runtimeBasePagePath, {
932
+ outputDir: path.dirname(filePath),
933
+ outputStructure: "split",
934
+ customPomAttachments: options.customPomAttachments ?? [],
935
+ projectRoot,
936
+ customPomDir: options.customPomDir,
937
+ customPomImportAliases: options.customPomImportAliases,
938
+ customPomClassIdentifierMap: customPomImportResolution.classIdentifierMap,
939
+ customPomAvailableClassIdentifiers: customPomImportResolution.availableClassIdentifiers,
940
+ customPomImportSpecifiersByClass: customPomImportResolution.importSpecifiersByClass,
941
+ customPomMethodSignaturesByClass: customPomImportResolution.methodSignaturesByClass,
942
+ generatedTsFilePathByComponent,
943
+ testIdAttribute: options.testIdAttribute,
944
+ vueRouterFluentChaining: options.vueRouterFluentChaining,
945
+ routeMetaByComponent: options.routeMetaByComponent,
946
+ });
947
+ files.push({ filePath, content });
948
+ }
949
+
950
+ for (const targetClassName of stubTargets) {
951
+ const filePath = generatedTsFilePathByComponent.get(targetClassName);
952
+ if (!filePath) {
953
+ continue;
954
+ }
955
+
956
+ const outputDir = path.dirname(filePath);
957
+ const basePageImportSpecifier = stripExtension(toPosixRelativePath(outputDir, runtimeBasePagePath));
958
+ const composed = getComposedStubBody(targetClassName, availableClassNames, depsByClassName, vueFilesPathMap, projectRoot);
959
+ const childImports = getChildImportSpecifiers(outputDir, composed?.childClassNames ?? [], generatedTsFilePathByComponent);
960
+ const members = composed?.members ?? getDefaultStubMembers();
961
+
962
+ const content = renderSplitStubPomContent({
963
+ className: targetClassName,
964
+ basePageImportSpecifier,
965
+ childImports,
966
+ members,
967
+ });
968
+
969
+ files.push({ filePath, content });
970
+ }
971
+
972
+ const runtimeAssetSpecs = getRuntimeGeneratedAssetSpecs(base, basePageClassPath);
973
+ const runtimeFiles = buildRuntimeGeneratedFilesFromSpecs(runtimeAssetSpecs);
974
+ const indexContent = renderSourceFile("index.ts", (sourceFile) => {
975
+ for (const spec of runtimeAssetSpecs) {
976
+ addExportAll(sourceFile, stripExtension(toPosixRelativePath(base, spec.outputPath)));
977
+ }
978
+ for (const [, filePath] of Array.from(generatedTsFilePathByComponent.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
979
+ addExportAll(sourceFile, `./${stripExtension(path.basename(filePath))}`);
980
+ }
981
+ }, {
982
+ prefixText: buildFilePrefix({
983
+ eslintDisableSortImports: true,
984
+ commentLines: [
985
+ "POM exports",
986
+ "DO NOT MODIFY BY HAND",
987
+ "",
988
+ "This file is auto-generated by vue-pom-generator.",
989
+ "Changes should be made in the generator/template, not in the generated output.",
990
+ ],
991
+ }),
992
+ });
993
+
994
+ return [
995
+ ...files,
996
+ { filePath: path.join(base, "index.ts"), content: indexContent },
997
+ ...runtimeFiles,
998
+ ];
999
+ }
1000
+
544
1001
  function escapeGitAttributesPattern(value: string): string {
545
1002
  let output = "";
546
1003
  for (let i = 0; i < value.length; i++) {
@@ -1123,88 +1580,191 @@ function maybeGenerateFixtureRegistry(
1123
1580
  })
1124
1581
  .filter((entry): entry is { className: string; localIdentifier: string; importSpecifier: string } => !!entry);
1125
1582
  const overrideCtorByClassName = new Map(overrideCtorEntries.map(entry => [entry.className, entry.localIdentifier]));
1126
- const overrideImports = overrideCtorEntries.length
1127
- ? `${overrideCtorEntries
1128
- .map(entry => `import { ${entry.className} as ${entry.localIdentifier} } from "${entry.importSpecifier}";`)
1129
- .join("\n")}\n\n`
1130
- : "";
1131
1583
 
1132
1584
  const fixtureCtorExpression = (name: string) => overrideCtorByClassName.get(name) ?? `Pom.${name}`;
1585
+ const pageCtorEntries = viewClassNames.map(name => ({
1586
+ fixtureName: lowerFirst(name),
1587
+ ctorExpression: fixtureCtorExpression(name),
1588
+ }));
1589
+ const componentCtorEntries = componentClassNames.map(name => ({
1590
+ fixtureName: lowerFirst(name),
1591
+ ctorExpression: fixtureCtorExpression(name),
1592
+ }));
1593
+
1594
+ const fixturesContent = renderSourceFile(fixtureFileName, (sourceFile) => {
1595
+ sourceFile.addStatements("/** Generated Playwright fixtures (typed page objects). */");
1596
+
1597
+ addNamedImport(sourceFile, {
1598
+ moduleSpecifier: "@playwright/test",
1599
+ namedImports: [
1600
+ "expect",
1601
+ { name: "test", alias: "base" },
1602
+ ],
1603
+ });
1604
+ addNamedImport(sourceFile, {
1605
+ moduleSpecifier: "@playwright/test",
1606
+ isTypeOnly: true,
1607
+ namedImports: [{ name: "Page", alias: "PwPage" }],
1608
+ });
1609
+ sourceFile.addImportDeclaration({
1610
+ namespaceImport: "Pom",
1611
+ moduleSpecifier: pomImport,
1612
+ });
1613
+ for (const entry of overrideCtorEntries) {
1614
+ addNamedImport(sourceFile, {
1615
+ moduleSpecifier: entry.importSpecifier,
1616
+ namedImports: [{ name: entry.className, alias: entry.localIdentifier }],
1617
+ });
1618
+ }
1133
1619
 
1134
- const header = `${eslintSuppressionHeader}/**\n`
1135
- + ` * DO NOT MODIFY BY HAND\n`
1136
- + ` *\n`
1137
- + ` * This file is auto-generated by vue-pom-generator.\n`
1138
- + ` * Changes should be made in the generator/template, not in the generated output.\n`
1139
- + ` */\n\n`;
1140
-
1141
- // Concrete, strongly-typed fixtures for Playwright tests.
1142
- // test("...", async ({ preferencesPage }) => { ... })
1143
- //
1144
- // View POMs implement goTo() directly, so fixtures can be strongly typed without
1145
- // casting/augmenting at runtime.
1146
- const fixturesTypeEntries = viewClassNames
1147
- .map(name => ` ${lowerFirst(name)}: ${fixtureCtorExpression(name)},`)
1148
- .join("\n");
1620
+ sourceFile.addInterface({
1621
+ isExported: true,
1622
+ name: "PlaywrightOptions",
1623
+ properties: [{
1624
+ name: "animation",
1625
+ type: "Pom.PlaywrightAnimationOptions",
1626
+ }],
1627
+ });
1628
+ sourceFile.addTypeAlias({
1629
+ isExported: true,
1630
+ name: "PomConstructor",
1631
+ typeParameters: [{ name: "T" }],
1632
+ type: "new (page: PwPage) => T",
1633
+ });
1634
+ sourceFile.addInterface({
1635
+ isExported: true,
1636
+ name: "PomFactory",
1637
+ methods: [{
1638
+ name: "create",
1639
+ typeParameters: [{ name: "T" }],
1640
+ parameters: [{ name: "ctor", type: "PomConstructor<T>" }],
1641
+ returnType: "T",
1642
+ }],
1643
+ });
1644
+ sourceFile.addTypeAlias({
1645
+ name: "PomSetupFixture",
1646
+ type: "{ pomSetup: void }",
1647
+ });
1648
+ sourceFile.addTypeAlias({
1649
+ name: "PomFactoryFixture",
1650
+ type: "{ pomFactory: PomFactory }",
1651
+ });
1149
1652
 
1150
- const componentFixturesTypeEntries = componentClassNames
1151
- .map(name => ` ${lowerFirst(name)}: ${fixtureCtorExpression(name)},`)
1152
- .join("\n");
1653
+ sourceFile.addVariableStatement({
1654
+ declarationKind: VariableDeclarationKind.Const,
1655
+ declarations: [{
1656
+ name: "pageCtors",
1657
+ initializer: (writer) => {
1658
+ writer.write("{").newLine();
1659
+ writer.indent(() => {
1660
+ for (const entry of pageCtorEntries) {
1661
+ writer.writeLine(`${entry.fixtureName}: ${entry.ctorExpression},`);
1662
+ }
1663
+ });
1664
+ writer.write("} as const");
1665
+ },
1666
+ }],
1667
+ });
1668
+ sourceFile.addVariableStatement({
1669
+ declarationKind: VariableDeclarationKind.Const,
1670
+ declarations: [{
1671
+ name: "componentCtors",
1672
+ initializer: (writer) => {
1673
+ writer.write("{").newLine();
1674
+ writer.indent(() => {
1675
+ for (const entry of componentCtorEntries) {
1676
+ writer.writeLine(`${entry.fixtureName}: ${entry.ctorExpression},`);
1677
+ }
1678
+ });
1679
+ writer.write("} as const");
1680
+ },
1681
+ }],
1682
+ });
1683
+
1684
+ sourceFile.addTypeAlias({
1685
+ isExported: true,
1686
+ name: "GeneratedPageFixtures",
1687
+ type: "{ [K in keyof typeof pageCtors]: InstanceType<(typeof pageCtors)[K]> }",
1688
+ });
1689
+ sourceFile.addTypeAlias({
1690
+ isExported: true,
1691
+ name: "GeneratedComponentFixtures",
1692
+ type: "{ [K in keyof typeof componentCtors]: InstanceType<(typeof componentCtors)[K]> }",
1693
+ });
1694
+
1695
+ sourceFile.addFunction({
1696
+ name: "makePomFixture",
1697
+ typeParameters: [{ name: "T" }],
1698
+ parameters: [{ name: "Ctor", type: "PomConstructor<T>" }],
1699
+ statements: [
1700
+ "return async ({ page }: { page: PwPage }, use: (t: T) => Promise<void>) => {",
1701
+ " await use(new Ctor(page));",
1702
+ "};",
1703
+ ],
1704
+ });
1705
+ sourceFile.addFunction({
1706
+ name: "createPomFixtures",
1707
+ typeParameters: [{ name: "TMap", constraint: "Record<string, PomConstructor<any>>" }],
1708
+ parameters: [{ name: "ctors", type: "TMap" }],
1709
+ statements: [
1710
+ "const out: Record<string, any> = {};",
1711
+ "for (const [key, Ctor] of Object.entries(ctors)) {",
1712
+ " out[key] = makePomFixture(Ctor as PomConstructor<any>);",
1713
+ "}",
1714
+ "return out as any;",
1715
+ ],
1716
+ });
1153
1717
 
1154
- const pomFactoryType = `export type PomConstructor<T> = new (page: PwPage) => T;\n\n`
1155
- + `export interface PomFactory {\n`
1156
- + ` create<T>(ctor: PomConstructor<T>): T;\n`
1157
- + `}\n\n`;
1158
-
1159
- // NOTE: We intentionally do not generate "openXPage" helpers.
1160
- // Each view POM provides goTo(), and tests call it explicitly.
1161
-
1162
- // Openers removed.
1163
-
1164
- const fixturesContent = `${header
1165
- }/** Generated Playwright fixtures (typed page objects). */\n\n`
1166
- + `import { expect, test as base } from "@playwright/test";\n`
1167
- + `import type { Page as PwPage } from "@playwright/test";\n`
1168
- + `import * as Pom from "${pomImport}";\n`
1169
- + `${overrideImports}`
1170
- + `export interface PlaywrightOptions {\n`
1171
- + ` animation: Pom.PlaywrightAnimationOptions;\n`
1172
- + `}\n\n`
1173
- + `${pomFactoryType}`
1174
- + `type PomSetupFixture = { pomSetup: void };\n`
1175
- + `type PomFactoryFixture = { pomFactory: PomFactory };\n\n`
1176
- + `const pageCtors = {\n${fixturesTypeEntries}\n} as const;\n`
1177
- + `const componentCtors = {\n${componentFixturesTypeEntries}\n} as const;\n\n`
1178
- + `export type GeneratedPageFixtures = { [K in keyof typeof pageCtors]: InstanceType<(typeof pageCtors)[K]> };\n`
1179
- + `export type GeneratedComponentFixtures = { [K in keyof typeof componentCtors]: InstanceType<(typeof componentCtors)[K]> };\n\n`
1180
- + `const makePomFixture = <T>(Ctor: PomConstructor<T>) => async ({ page }: { page: PwPage }, use: (t: T) => Promise<void>) => {\n`
1181
- + ` await use(new Ctor(page));\n`
1182
- + `};\n\n`
1183
- + `const createPomFixtures = <TMap extends Record<string, PomConstructor<any>>>(ctors: TMap) => {\n`
1184
- + ` const out: Record<string, any> = {};\n`
1185
- + ` for (const [key, Ctor] of Object.entries(ctors)) {\n`
1186
- + ` out[key] = makePomFixture(Ctor as PomConstructor<any>);\n`
1187
- + ` }\n`
1188
- + ` return out as any;\n`
1189
- + `};\n\n`
1190
- + `const test = base.extend<PlaywrightOptions & PomSetupFixture & PomFactoryFixture & GeneratedPageFixtures & GeneratedComponentFixtures>({\n`
1191
- + ` animation: [{\n`
1192
- + ` pointer: { durationMilliseconds: 250, transitionStyle: "ease-in-out", clickDelayMilliseconds: 0 },\n`
1193
- + ` keyboard: { typeDelayMilliseconds: 100 },\n`
1194
- + ` }, { option: true }],\n`
1195
- + ` pomSetup: [async ({ animation }, use) => {\n`
1196
- + ` Pom.setPlaywrightAnimationOptions(animation);\n`
1197
- + ` await use();\n`
1198
- + ` }, { auto: true }],\n`
1199
- + ` pomFactory: async ({ page }, use) => {\n`
1200
- + ` await use({\n`
1201
- + ` create: <T>(ctor: PomConstructor<T>) => new ctor(page),\n`
1202
- + ` });\n`
1203
- + ` },\n`
1204
- + ` ...createPomFixtures(pageCtors),\n`
1205
- + ` ...createPomFixtures(componentCtors),\n`
1206
- + `});\n\n`
1207
- + `export { test, expect };\n`;
1718
+ sourceFile.addVariableStatement({
1719
+ declarationKind: VariableDeclarationKind.Const,
1720
+ declarations: [{
1721
+ name: "test",
1722
+ initializer: (writer) => {
1723
+ writer.write("base.extend<PlaywrightOptions & PomSetupFixture & PomFactoryFixture & GeneratedPageFixtures & GeneratedComponentFixtures>(");
1724
+ writer.block(() => {
1725
+ writer.writeLine("animation: [{");
1726
+ writer.indent(() => {
1727
+ writer.writeLine('pointer: { durationMilliseconds: 250, transitionStyle: "ease-in-out", clickDelayMilliseconds: 0 },');
1728
+ writer.writeLine("keyboard: { typeDelayMilliseconds: 100 },");
1729
+ });
1730
+ writer.writeLine("}, { option: true }],");
1731
+ writer.writeLine("pomSetup: [async ({ animation }, use) => {");
1732
+ writer.indent(() => {
1733
+ writer.writeLine("Pom.setPlaywrightAnimationOptions(animation);");
1734
+ writer.writeLine("await use();");
1735
+ });
1736
+ writer.writeLine("}, { auto: true }],");
1737
+ writer.writeLine("pomFactory: async ({ page }, use) => {");
1738
+ writer.indent(() => {
1739
+ writer.writeLine("await use({");
1740
+ writer.indent(() => {
1741
+ writer.writeLine("create: <T>(ctor: PomConstructor<T>) => new ctor(page),");
1742
+ });
1743
+ writer.writeLine("});");
1744
+ });
1745
+ writer.writeLine("},");
1746
+ writer.writeLine("...createPomFixtures(pageCtors),");
1747
+ writer.writeLine("...createPomFixtures(componentCtors),");
1748
+ });
1749
+ writer.write(")");
1750
+ },
1751
+ }],
1752
+ });
1753
+
1754
+ sourceFile.addExportDeclaration({
1755
+ namedExports: ["test", "expect"],
1756
+ });
1757
+ }, {
1758
+ prefixText: buildFilePrefix({
1759
+ eslintDisableSortImports: true,
1760
+ commentLines: [
1761
+ "DO NOT MODIFY BY HAND",
1762
+ "",
1763
+ "This file is auto-generated by vue-pom-generator.",
1764
+ "Changes should be made in the generator/template, not in the generated output.",
1765
+ ],
1766
+ }),
1767
+ });
1208
1768
 
1209
1769
  return {
1210
1770
  filePath: path.resolve(fixtureOutDirAbs, fixtureFileName),
@@ -1214,19 +1774,14 @@ function maybeGenerateFixtureRegistry(
1214
1774
  // No pomFixture is generated; goToSelf is emitted directly on each view POM.
1215
1775
  }
1216
1776
 
1217
- function generateViewObjectModelContent(
1777
+ function prepareViewObjectModelClass(
1218
1778
  componentName: string,
1219
1779
  dependencies: IComponentDependencies,
1220
1780
  componentHierarchyMap: Map<string, IComponentDependencies>,
1221
- vueFilesPathMap: Map<string, string>,
1222
- basePageClassPath: string,
1223
1781
  options: GenerateContentOptions = {},
1224
1782
  ) {
1225
- const { isView, childrenComponentSet, usedComponentSet, filePath } = dependencies;
1226
-
1783
+ const { isView, childrenComponentSet, usedComponentSet } = dependencies;
1227
1784
  const {
1228
- outputDir = path.dirname(filePath),
1229
- aggregated = false,
1230
1785
  customPomAttachments = [],
1231
1786
  testIdAttribute,
1232
1787
  } = options;
@@ -1271,66 +1826,15 @@ function generateViewObjectModelContent(
1271
1826
  : new Map<string, CustomPomMethodSignature>(),
1272
1827
  }));
1273
1828
 
1274
- let content: string = "";
1275
-
1276
- const sourceRel = toPosixRelativePath(outputDir, filePath);
1277
- const kind = isView ? "Page" : "Component";
1278
- const doc = `/** ${kind} POM: ${componentName} (source: ${sourceRel}) */\n`;
1279
-
1280
- // In aggregated mode, imports are hoisted once at the top of the file.
1281
- if (!aggregated) {
1282
- content = `${eslintSuppressionHeader}${doc}`;
1283
-
1284
- // We only need PwPage when we emit a constructor (views always do; components only do
1285
- // when they have custom attachments like Grid).
1286
- if (isView || attachmentsForThisClass.length > 0) {
1287
- content += "import type { Page as PwPage } from \"@playwright/test\";\n";
1288
- }
1289
-
1290
- const projectRoot = options.projectRoot ?? process.cwd();
1291
- const fromAbs = path.isAbsolute(outputDir) ? outputDir : path.resolve(projectRoot, outputDir);
1292
- const toAbs = basePageClassPath
1293
- ? (path.isAbsolute(basePageClassPath) ? basePageClassPath : path.resolve(projectRoot, basePageClassPath))
1294
- : "";
1295
- const basePageImport = path.relative(fromAbs, toAbs).replace(/\\/g, "/");
1296
- // stripExtension uses node:path formatting (platform-specific). Re-normalize to POSIX
1297
- // so the import specifier is valid on Windows.
1298
- const basePageImportNoExt = stripExtension(basePageImport).replace(/\\/g, "/");
1299
- const basePageImportSpecifier = basePageImportNoExt.startsWith(".") ? basePageImportNoExt : `./${basePageImportNoExt}`;
1300
- content += `import { BasePage, Fluent } from '${basePageImportSpecifier}';\n\n`;
1301
-
1302
- if (isView && childrenComponentSet.size > 0) {
1303
- childrenComponentSet.forEach((child) => {
1304
- if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
1305
- const childPath = vueFilesPathMap.get(child);
1306
- let relativePath = path.relative(outputDir, childPath || "");
1307
- relativePath = changeExtension(relativePath, ".vue", ".g").replace(/\\/g, "/");
1308
- content += `import { ${child} } from '${relativePath}';\n`;
1309
- }
1310
- });
1311
- }
1312
- }
1313
- else {
1314
- // Keep per-class doc comment, but avoid repeating eslint suppression / imports.
1315
- content = doc;
1316
- }
1317
-
1318
- // Convert raw component name (may contain hyphens/dots, e.g. "error-test", "FirmsGrid.client")
1319
- // to a valid PascalCase TypeScript identifier for the class declaration.
1320
- const className = toPascalCaseLocal(componentName);
1321
- content += `\nexport class ${className} extends BasePage {\n`;
1322
-
1323
1829
  const widgetInstances = isView
1324
1830
  ? getWidgetInstancesForView(componentName, dependencies.dataTestIdSet, customPomAvailableClassIdentifiers)
1325
1831
  : [];
1326
1832
 
1327
- // For views, `childrenComponentSet` only includes component tags on which we applied a data-testid.
1328
- // Thin wrapper views (e.g. NewTenantPage) may have *no* generated test ids but still contain
1329
- // important child component POMs (forms, grids, etc). In those cases, we use `usedComponentSet`
1330
- // to discover and instantiate child component POMs.
1331
1833
  const componentRefsForInstances = isView
1332
1834
  ? (usedComponentSet?.size ? usedComponentSet : childrenComponentSet)
1333
1835
  : childrenComponentSet;
1836
+
1837
+ const className = toPascalCaseLocal(componentName);
1334
1838
  const childInstancePropertyNames = Array.from(componentRefsForInstances)
1335
1839
  .filter(child => componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size)
1336
1840
  .map(child => child.split(".vue")[0]);
@@ -1345,63 +1849,194 @@ function generateViewObjectModelContent(
1345
1849
  ...childInstancePropertyNames,
1346
1850
  ]);
1347
1851
 
1348
- // Only views get child component instance fields by default.
1349
- // Components will only get a constructor/fields when they have explicit custom attachments
1350
- // (e.g. wrapper components around a third-party data grid should get a `grid: Grid`).
1852
+ const members: TypeScriptClassMember[] = [];
1351
1853
  if (isView && (componentRefsForInstances.size > 0 || attachmentsForThisClass.length > 0 || widgetInstances.length > 0)) {
1352
- content += getComponentInstances(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances);
1353
- content += getConstructor(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances, { testIdAttribute });
1854
+ members.push(...getComponentInstances(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances));
1855
+ members.push(getConstructor(componentRefsForInstances, componentHierarchyMap, attachmentsForThisClass, widgetInstances, { testIdAttribute }));
1354
1856
  }
1355
1857
  if (!isView && attachmentsForThisClass.length > 0) {
1356
- content += getComponentInstances(new Set(), componentHierarchyMap, attachmentsForThisClass);
1357
- content += getConstructor(new Set(), componentHierarchyMap, attachmentsForThisClass, [], { testIdAttribute });
1358
- }
1359
- content += getAttachmentPassthroughMethods(componentName, dependencies, attachmentsForThisClass, reservedAttachmentPassthroughNames);
1360
-
1361
- // Ergonomics: when a view is primarily composed of a single component POM (e.g. a form),
1362
- // allow calling that component's methods directly on the page class.
1363
- //
1364
- // Example:
1365
- // await tenantListPage.goToNewTenant().typeTenantName(...).clickCreateTenant();
1366
- //
1367
- // Rules:
1368
- // - Only for views (not components) to avoid polluting component surfaces.
1369
- // - Only generate pass-throughs when the method is unambiguous across child components.
1370
- // - Never generate a pass-through that would collide with an existing method on the view.
1371
- // Only generate view passthrough methods when the view is essentially a thin wrapper
1372
- // around a single child component POM. This prevents "layout" components (Page, PageHeader,
1373
- // etc.) from injecting lots of noisy passthrough APIs into every view.
1858
+ members.push(...getComponentInstances(new Set(), componentHierarchyMap, attachmentsForThisClass));
1859
+ members.push(getConstructor(new Set(), componentHierarchyMap, attachmentsForThisClass, [], { testIdAttribute }));
1860
+ }
1861
+
1862
+ members.push(
1863
+ ...getAttachmentPassthroughMethods(componentName, dependencies, attachmentsForThisClass, reservedAttachmentPassthroughNames),
1864
+ );
1865
+
1374
1866
  if (isView && componentRefsForInstances.size === 1) {
1375
- content += getViewPassthroughMethods(componentName, dependencies, componentRefsForInstances, componentHierarchyMap, blockedViewPassthroughMethodNames);
1867
+ members.push(
1868
+ ...getViewPassthroughMethods(
1869
+ componentName,
1870
+ dependencies,
1871
+ componentRefsForInstances,
1872
+ componentHierarchyMap,
1873
+ blockedViewPassthroughMethodNames,
1874
+ ),
1875
+ );
1376
1876
  }
1377
1877
 
1378
1878
  if (isView && options.vueRouterFluentChaining) {
1379
1879
  const routeMeta = options.routeMetaByComponent?.[componentName] ?? null;
1380
- content += generateRouteProperty(routeMeta);
1381
- // Pass className (PascalCase) so the generated route self-reference is a valid identifier.
1382
- content += generateGoToSelfMethod(className);
1880
+ members.push(...generateRouteProperty(routeMeta));
1881
+ members.push(...generateGoToSelfMethod(className));
1383
1882
  }
1384
1883
 
1385
- content += generateMethodsContentForDependencies(dependencies);
1884
+ members.push(...generateMethodsContentForDependencies(dependencies));
1386
1885
 
1387
- content += "}\n";
1388
- return content;
1886
+ return {
1887
+ className,
1888
+ componentRefsForInstances,
1889
+ attachmentsForThisClass,
1890
+ widgetInstances,
1891
+ isView,
1892
+ members,
1893
+ };
1389
1894
  }
1390
1895
 
1391
- function getViewPassthroughMethods(
1392
- viewName: string,
1393
- viewDependencies: IComponentDependencies,
1394
- childrenComponentSet: Set<string>,
1896
+ function generateViewObjectModelContent(
1897
+ componentName: string,
1898
+ dependencies: IComponentDependencies,
1395
1899
  componentHierarchyMap: Map<string, IComponentDependencies>,
1396
- blockedMethodNames: Set<string> = new Set(),
1900
+ _vueFilesPathMap: Map<string, string>,
1901
+ basePageClassPath: string,
1902
+ options: GenerateContentOptions = {},
1397
1903
  ) {
1398
- const existingOnView = viewDependencies.generatedMethods ?? new Map<string, { params: string; argNames: string[] } | null>();
1904
+ const { filePath } = dependencies;
1905
+ const outputDir = options.outputDir ?? path.dirname(filePath);
1906
+ const prepared = prepareViewObjectModelClass(componentName, dependencies, componentHierarchyMap, options);
1907
+ const sourceRel = toPosixRelativePath(outputDir, filePath);
1908
+ const kind = prepared.isView ? "Page" : "Component";
1909
+ const doc = `/** ${kind} POM: ${componentName} (source: ${sourceRel}) */`;
1910
+ const projectRoot = options.projectRoot ?? process.cwd();
1911
+ const fromAbs = path.isAbsolute(outputDir) ? outputDir : path.resolve(projectRoot, outputDir);
1912
+ const toAbs = basePageClassPath
1913
+ ? (path.isAbsolute(basePageClassPath) ? basePageClassPath : path.resolve(projectRoot, basePageClassPath))
1914
+ : "";
1915
+ const basePageImport = path.relative(fromAbs, toAbs).replace(/\\/g, "/");
1916
+ const basePageImportNoExt = stripExtension(basePageImport).replace(/\\/g, "/");
1917
+ const basePageImportSpecifier = basePageImportNoExt.startsWith(".") ? basePageImportNoExt : `./${basePageImportNoExt}`;
1918
+ const needsPlaywrightPageImport = prepared.isView || prepared.attachmentsForThisClass.length > 0;
1919
+ const customPomImportSpecifiersByClass = options.customPomImportSpecifiersByClass ?? {};
1920
+
1921
+ const customImports = Array.from(
1922
+ new Set([
1923
+ ...prepared.attachmentsForThisClass.map(attachment => attachment.className),
1924
+ ...prepared.widgetInstances.map(widget => widget.className),
1925
+ ]),
1926
+ )
1927
+ .reduce<Array<{ moduleSpecifier: string; name: string; alias?: string }>>((imports, localIdentifier) => {
1928
+ const specifier = Object.values(customPomImportSpecifiersByClass)
1929
+ .find(spec => spec.localIdentifier === localIdentifier);
1930
+ if (!specifier) {
1931
+ return imports;
1932
+ }
1399
1933
 
1400
- // methodName -> candidates
1401
- const methodToChildren = new Map<string, Array<{ childProp: string; params: string; argNames: string[] }>>();
1934
+ imports.push({
1935
+ moduleSpecifier: stripExtension(toPosixRelativePath(fromAbs, specifier.absolutePath)),
1936
+ name: specifier.exportName,
1937
+ alias: specifier.localIdentifier !== specifier.exportName ? specifier.localIdentifier : undefined,
1938
+ });
1939
+ return imports;
1940
+ }, [])
1941
+ .sort((a, b) => (a.alias ?? a.name).localeCompare(b.alias ?? b.name));
1402
1942
 
1403
- for (const child of childrenComponentSet) {
1404
- const childDeps = componentHierarchyMap.get(child);
1943
+ const generatedImports: Array<{ className: string; moduleSpecifier: string }> = [];
1944
+ const importedGeneratedClasses = new Set<string>();
1945
+ const generatedTsFilePathByComponent = options.generatedTsFilePathByComponent;
1946
+
1947
+ const addGeneratedImport = (className: string) => {
1948
+ if (!generatedTsFilePathByComponent || importedGeneratedClasses.has(className) || className === componentName) {
1949
+ return;
1950
+ }
1951
+ const generatedFilePath = generatedTsFilePathByComponent.get(className);
1952
+ if (!generatedFilePath) {
1953
+ return;
1954
+ }
1955
+
1956
+ generatedImports.push({
1957
+ className,
1958
+ moduleSpecifier: stripExtension(toPosixRelativePath(fromAbs, generatedFilePath)),
1959
+ });
1960
+ importedGeneratedClasses.add(className);
1961
+ };
1962
+
1963
+ for (const child of prepared.componentRefsForInstances) {
1964
+ const childName = child.endsWith(".vue") ? child.slice(0, -4) : child;
1965
+ const childDeps = componentHierarchyMap.get(child) ?? componentHierarchyMap.get(childName);
1966
+ if (childDeps?.dataTestIdSet.size) {
1967
+ addGeneratedImport(childName);
1968
+ }
1969
+ }
1970
+
1971
+ const targetClassNames = Array.from(
1972
+ new Set(
1973
+ Array.from(dependencies.dataTestIdSet ?? [])
1974
+ .map(entry => entry.targetPageObjectModelClass)
1975
+ .filter((target): target is string => typeof target === "string" && target.length > 0),
1976
+ ),
1977
+ ).sort((a, b) => a.localeCompare(b));
1978
+
1979
+ for (const targetClassName of targetClassNames) {
1980
+ addGeneratedImport(targetClassName);
1981
+ }
1982
+
1983
+ generatedImports.sort((a, b) => a.className.localeCompare(b.className));
1984
+
1985
+ const prefixText = `${buildFilePrefix({ eslintDisableSortImports: true })}${doc}\n`;
1986
+ return renderSourceFile(`${prepared.className}.ts`, (sourceFile) => {
1987
+ if (needsPlaywrightPageImport) {
1988
+ addNamedImport(sourceFile, {
1989
+ moduleSpecifier: "@playwright/test",
1990
+ isTypeOnly: true,
1991
+ namedImports: [{ name: "Page", alias: "PwPage" }],
1992
+ });
1993
+ }
1994
+
1995
+ addNamedImport(sourceFile, {
1996
+ moduleSpecifier: basePageImportSpecifier,
1997
+ namedImports: ["BasePage", "Fluent"],
1998
+ });
1999
+
2000
+ for (const customImport of customImports) {
2001
+ addNamedImport(sourceFile, {
2002
+ moduleSpecifier: customImport.moduleSpecifier,
2003
+ namedImports: [{ name: customImport.name, alias: customImport.alias }],
2004
+ });
2005
+ }
2006
+
2007
+ for (const generatedImport of generatedImports) {
2008
+ addNamedImport(sourceFile, {
2009
+ moduleSpecifier: generatedImport.moduleSpecifier,
2010
+ namedImports: [generatedImport.className],
2011
+ });
2012
+ }
2013
+
2014
+ const classDeclaration = sourceFile.addClass({
2015
+ name: prepared.className,
2016
+ isExported: true,
2017
+ extends: "BasePage",
2018
+ });
2019
+
2020
+ for (const member of prepared.members) {
2021
+ addClassMember(classDeclaration, member);
2022
+ }
2023
+ }, { prefixText });
2024
+ }
2025
+
2026
+ function getViewPassthroughMethods(
2027
+ viewName: string,
2028
+ viewDependencies: IComponentDependencies,
2029
+ childrenComponentSet: Set<string>,
2030
+ componentHierarchyMap: Map<string, IComponentDependencies>,
2031
+ blockedMethodNames: Set<string> = new Set(),
2032
+ ) {
2033
+ const existingOnView = viewDependencies.generatedMethods ?? new Map<string, { params: string; argNames: string[] } | null>();
2034
+
2035
+ // methodName -> candidates
2036
+ const methodToChildren = new Map<string, Array<{ childProp: string; params: string; argNames: string[] }>>();
2037
+
2038
+ for (const child of childrenComponentSet) {
2039
+ const childDeps = componentHierarchyMap.get(child);
1405
2040
  if (!childDeps || !childDeps.dataTestIdSet?.size)
1406
2041
  continue;
1407
2042
 
@@ -1427,34 +2062,23 @@ function getViewPassthroughMethods(
1427
2062
  }
1428
2063
 
1429
2064
  const sorted = Array.from(methodToChildren.entries()).sort((a, b) => a[0].localeCompare(b[0]));
1430
- const lines: string[] = [];
1431
-
1432
- for (const [methodName, candidates] of sorted) {
1433
- // Only generate when exactly one child can satisfy the call.
1434
- if (candidates.length !== 1)
1435
- continue;
2065
+ const passthroughs = sorted.filter(([, candidates]) => candidates.length === 1);
2066
+ if (!passthroughs.length) {
2067
+ return [];
2068
+ }
1436
2069
 
2070
+ return passthroughs.map(([methodName, candidates]) => {
1437
2071
  const { childProp, params, argNames } = candidates[0];
1438
2072
  const callArgs = argNames.join(", ");
1439
-
1440
- lines.push(
1441
- "",
1442
- ` async ${methodName}(${params}) {`,
1443
- ` return await this.${childProp}.${methodName}(${callArgs});`,
1444
- " }",
1445
- );
1446
- }
1447
-
1448
- if (!lines.length) {
1449
- return "";
1450
- }
1451
-
1452
- return [
1453
- "",
1454
- ` // Passthrough methods composed from child component POMs of ${viewName}.`,
1455
- ...lines,
1456
- "",
1457
- ].join("\n");
2073
+ return createClassMethod({
2074
+ name: methodName,
2075
+ isAsync: true,
2076
+ parameters: parseParameterSignatures(params),
2077
+ statements: [
2078
+ `return await this.${childProp}.${methodName}(${callArgs});`,
2079
+ ],
2080
+ });
2081
+ });
1458
2082
  }
1459
2083
 
1460
2084
  function getAttachmentPassthroughMethods(
@@ -1464,7 +2088,7 @@ function getAttachmentPassthroughMethods(
1464
2088
  reservedMemberNames: Set<string>,
1465
2089
  ) {
1466
2090
  if (!attachmentsForThisClass.some(a => a.flatten && a.methodSignatures.size > 0)) {
1467
- return "";
2091
+ return [];
1468
2092
  }
1469
2093
 
1470
2094
  const existingOnClass = ownerDependencies.generatedMethods ?? new Map<string, { params: string; argNames: string[] } | null>();
@@ -1491,37 +2115,25 @@ function getAttachmentPassthroughMethods(
1491
2115
  }
1492
2116
 
1493
2117
  const sorted = Array.from(methodToAttachments.entries()).sort((a, b) => a[0].localeCompare(b[0]));
1494
- const lines: string[] = [];
1495
-
1496
- for (const [methodName, candidates] of sorted) {
1497
- if (candidates.length !== 1) {
1498
- continue;
1499
- }
2118
+ const passthroughs = sorted.filter(([, candidates]) => candidates.length === 1);
2119
+ if (!passthroughs.length) {
2120
+ return [];
2121
+ }
1500
2122
 
2123
+ return passthroughs.map(([methodName, candidates]) => {
1501
2124
  const { propertyName, params, argNames } = candidates[0];
1502
2125
  const callArgs = argNames.join(", ");
1503
2126
  const invocation = callArgs
1504
2127
  ? `this.${propertyName}.${methodName}(${callArgs})`
1505
2128
  : `this.${propertyName}.${methodName}()`;
1506
-
1507
- lines.push(
1508
- "",
1509
- ` ${methodName}(${params}) {`,
1510
- ` return ${invocation};`,
1511
- " }",
1512
- );
1513
- }
1514
-
1515
- if (!lines.length) {
1516
- return "";
1517
- }
1518
-
1519
- return [
1520
- "",
1521
- ` // Passthrough methods composed from custom helper attachments of ${ownerName}.`,
1522
- ...lines,
1523
- "",
1524
- ].join("\n");
2129
+ return createClassMethod({
2130
+ name: methodName,
2131
+ parameters: parseParameterSignatures(params),
2132
+ statements: [
2133
+ `return ${invocation};`,
2134
+ ],
2135
+ });
2136
+ });
1525
2137
  }
1526
2138
 
1527
2139
  function sliceNodeSource(source: string, node: { start?: number | null; end?: number | null }): string | null {
@@ -1628,6 +2240,373 @@ function ensureDir(dir: string) {
1628
2240
  return normalized;
1629
2241
  }
1630
2242
 
2243
+ function resolvePluginAsset(relative: string): string {
2244
+ try {
2245
+ return fileURLToPath(new URL(relative, import.meta.url));
2246
+ }
2247
+ catch {
2248
+ return path.resolve(__dirname, relative);
2249
+ }
2250
+ }
2251
+
2252
+ function readTextAsset(absPath: string, description: string): string {
2253
+ try {
2254
+ return fs.readFileSync(absPath, "utf8");
2255
+ }
2256
+ catch {
2257
+ throw new VuePomGeneratorError(`Failed to read ${description} at ${absPath}`);
2258
+ }
2259
+ }
2260
+
2261
+ function getDefaultStubMembers(): TypeScriptClassMember[] {
2262
+ return [
2263
+ createClassConstructor({
2264
+ parameters: [{ name: "page", type: "PwPage" }],
2265
+ statements: [
2266
+ "super(page);",
2267
+ ],
2268
+ }),
2269
+ ];
2270
+ }
2271
+
2272
+ function renderSplitStubPomContent(options: {
2273
+ className: string;
2274
+ basePageImportSpecifier: string;
2275
+ childImports: Array<{ className: string; importPath: string }>;
2276
+ members: TypeScriptClassMember[];
2277
+ }): string {
2278
+ const prefixText = buildFilePrefix({
2279
+ eslintDisableSortImports: true,
2280
+ commentLines: [
2281
+ `Stub POM: ${options.className}`,
2282
+ "DO NOT MODIFY BY HAND",
2283
+ "",
2284
+ "This file is auto-generated by vue-pom-generator.",
2285
+ "Changes should be made in the generator/template, not in the generated output.",
2286
+ ],
2287
+ });
2288
+
2289
+ return renderSourceFile(`${options.className}.ts`, (sourceFile) => {
2290
+ addNamedImport(sourceFile, {
2291
+ moduleSpecifier: "@playwright/test",
2292
+ isTypeOnly: true,
2293
+ namedImports: [{ name: "Page", alias: "PwPage" }],
2294
+ });
2295
+ addNamedImport(sourceFile, {
2296
+ moduleSpecifier: options.basePageImportSpecifier,
2297
+ namedImports: ["BasePage"],
2298
+ });
2299
+ for (const childImport of options.childImports) {
2300
+ addNamedImport(sourceFile, {
2301
+ moduleSpecifier: childImport.importPath,
2302
+ namedImports: [childImport.className],
2303
+ });
2304
+ }
2305
+ sourceFile.addStatements(buildCommentBlock([
2306
+ "Stub POM generated because it is referenced as a navigation target but",
2307
+ "did not have any generated test ids in this build.",
2308
+ ]).trimEnd());
2309
+ const classDeclaration = sourceFile.addClass({
2310
+ name: options.className,
2311
+ isExported: true,
2312
+ extends: "BasePage",
2313
+ });
2314
+ for (const member of options.members) {
2315
+ addClassMember(classDeclaration, member);
2316
+ }
2317
+ }, { prefixText });
2318
+ }
2319
+
2320
+ function getChildImportSpecifiers(
2321
+ outputDir: string,
2322
+ childClassNames: string[],
2323
+ generatedTsFilePathByComponent: Map<string, string>,
2324
+ ): Array<{ className: string; importPath: string }> {
2325
+ return childClassNames
2326
+ .map((childClassName) => {
2327
+ const childFilePath = generatedTsFilePathByComponent.get(childClassName);
2328
+ if (!childFilePath) {
2329
+ return null;
2330
+ }
2331
+ return {
2332
+ className: childClassName,
2333
+ importPath: stripExtension(toPosixRelativePath(outputDir, childFilePath)),
2334
+ };
2335
+ })
2336
+ .filter((entry): entry is { className: string; importPath: string } => !!entry)
2337
+ .sort((a, b) => a.className.localeCompare(b.className));
2338
+ }
2339
+
2340
+ function isConstructorMember(member: TypeScriptClassMember): member is OptionalKind<ConstructorDeclarationStructure> {
2341
+ return member.kind === StructureKind.Constructor;
2342
+ }
2343
+
2344
+ function isGetterMember(member: TypeScriptClassMember): member is OptionalKind<GetAccessorDeclarationStructure> {
2345
+ return member.kind === StructureKind.GetAccessor;
2346
+ }
2347
+
2348
+ function isMethodMember(member: TypeScriptClassMember): member is OptionalKind<MethodDeclarationStructure> {
2349
+ return member.kind === StructureKind.Method;
2350
+ }
2351
+
2352
+ function isPropertyMember(member: TypeScriptClassMember): member is OptionalKind<PropertyDeclarationStructure> {
2353
+ return member.kind === StructureKind.Property;
2354
+ }
2355
+
2356
+ function addClassMember(classDeclaration: ReturnType<TypeScriptSourceFile["addClass"]>, member: TypeScriptClassMember): void {
2357
+ if (isConstructorMember(member)) {
2358
+ classDeclaration.addConstructor(member);
2359
+ return;
2360
+ }
2361
+ if (isGetterMember(member)) {
2362
+ classDeclaration.addGetAccessor(member);
2363
+ return;
2364
+ }
2365
+ if (isMethodMember(member)) {
2366
+ classDeclaration.addMethod(member);
2367
+ return;
2368
+ }
2369
+ if (isPropertyMember(member)) {
2370
+ classDeclaration.addProperty(member);
2371
+ return;
2372
+ }
2373
+ throw new Error(`Unsupported class member structure: ${String(member)}`);
2374
+ }
2375
+
2376
+ interface RuntimeGeneratedAssetSpec {
2377
+ absolutePath: string;
2378
+ description: string;
2379
+ outputPath: string;
2380
+ }
2381
+
2382
+ function getRuntimeGeneratedAssetSpecs(baseDir: string, basePageClassPath: string): RuntimeGeneratedAssetSpec[] {
2383
+ const runtimeDirAbs = path.join(baseDir, "_pom-runtime");
2384
+ const runtimeClassGenAbs = path.join(runtimeDirAbs, "class-generation");
2385
+ const runtimeClassGenSourceDir = resolvePluginAsset("../class-generation");
2386
+ const runtimeClassGenFiles = fs.readdirSync(runtimeClassGenSourceDir)
2387
+ .filter(file => file.endsWith(".ts"))
2388
+ .filter(file => file !== "BasePage.ts" && file !== "index.ts")
2389
+ .sort((left, right) => left.localeCompare(right));
2390
+
2391
+ return [
2392
+ {
2393
+ absolutePath: resolvePluginAsset("../click-instrumentation.ts"),
2394
+ description: "click-instrumentation.ts",
2395
+ outputPath: path.join(runtimeDirAbs, "click-instrumentation.ts"),
2396
+ },
2397
+ ...runtimeClassGenFiles.map(file => ({
2398
+ absolutePath: path.join(runtimeClassGenSourceDir, file),
2399
+ description: file,
2400
+ outputPath: path.join(runtimeClassGenAbs, file),
2401
+ })),
2402
+ {
2403
+ absolutePath: basePageClassPath,
2404
+ description: "BasePage.ts",
2405
+ outputPath: path.join(runtimeClassGenAbs, "BasePage.ts"),
2406
+ },
2407
+ ];
2408
+ }
2409
+
2410
+ function buildRuntimeGeneratedFiles(baseDir: string, basePageClassPath: string): GeneratedFileOutput[] {
2411
+ return buildRuntimeGeneratedFilesFromSpecs(getRuntimeGeneratedAssetSpecs(baseDir, basePageClassPath));
2412
+ }
2413
+
2414
+ function buildRuntimeGeneratedFilesFromSpecs(assetSpecs: RuntimeGeneratedAssetSpec[]): GeneratedFileOutput[] {
2415
+ return assetSpecs.map(spec => ({
2416
+ filePath: spec.outputPath,
2417
+ content: readTextAsset(spec.absolutePath, spec.description),
2418
+ }));
2419
+ }
2420
+
2421
+ function buildRuntimeGeneratedBarrelExports(outputDir: string, assetSpecs: RuntimeGeneratedAssetSpec[]): string[] {
2422
+ return assetSpecs.map(spec => `export * from "${stripExtension(toPosixRelativePath(outputDir, spec.outputPath))}";`);
2423
+ }
2424
+
2425
+ function resolveCustomPomImportResolution(
2426
+ generatedClassNames: Set<string>,
2427
+ projectRoot: string,
2428
+ options: {
2429
+ customPomDir?: GenerateFilesOptions["customPomDir"];
2430
+ customPomImportAliases?: GenerateFilesOptions["customPomImportAliases"];
2431
+ customPomImportNameCollisionBehavior?: GenerateFilesOptions["customPomImportNameCollisionBehavior"];
2432
+ } = {},
2433
+ ): CustomPomImportResolution {
2434
+ const importAliases: Record<string, string> = {
2435
+ Toggle: "ToggleWidget",
2436
+ Checkbox: "CheckboxWidget",
2437
+ ...(options.customPomImportAliases),
2438
+ };
2439
+ const importCollisionBehavior = options.customPomImportNameCollisionBehavior ?? "error";
2440
+
2441
+ const reservedIdentifiers = new Set<string>([
2442
+ "PwLocator",
2443
+ "PwPage",
2444
+ "BasePage",
2445
+ "Fluent",
2446
+ ...generatedClassNames,
2447
+ ]);
2448
+ const usedImportIdentifiers = new Set<string>();
2449
+ const classIdentifierMap: Record<string, string> = {};
2450
+ const methodSignaturesByClass = new Map<string, CustomPomMethodSignatureMap>();
2451
+ const importSpecifiersByClass: Record<string, ResolvedCustomPomImportSpecifier> = {};
2452
+
2453
+ const ensureUniqueIdentifier = (base: string) => {
2454
+ let candidate = base;
2455
+ let i = 2;
2456
+ while (reservedIdentifiers.has(candidate) || usedImportIdentifiers.has(candidate)) {
2457
+ candidate = `${base}${i}`;
2458
+ i++;
2459
+ }
2460
+ usedImportIdentifiers.add(candidate);
2461
+ return candidate;
2462
+ };
2463
+
2464
+ const customDirRelOrAbs = options.customPomDir ?? "tests/playwright/pom/custom";
2465
+ const customDirAbs = path.isAbsolute(customDirRelOrAbs)
2466
+ ? customDirRelOrAbs
2467
+ : path.resolve(projectRoot, customDirRelOrAbs);
2468
+
2469
+ if (!fs.existsSync(customDirAbs)) {
2470
+ return {
2471
+ classIdentifierMap,
2472
+ methodSignaturesByClass,
2473
+ availableClassIdentifiers: new Set<string>(),
2474
+ importSpecifiersByClass,
2475
+ };
2476
+ }
2477
+
2478
+ const files = fs.readdirSync(customDirAbs)
2479
+ .filter(f => f.endsWith(".ts"))
2480
+ .sort((a, b) => a.localeCompare(b));
2481
+
2482
+ for (const file of files) {
2483
+ const exportName = file.replace(/\.ts$/i, "");
2484
+ const requested = importAliases[exportName] ?? exportName;
2485
+ const collidesWithGeneratedClass = generatedClassNames.has(requested);
2486
+ const explicitAliasProvided = Object.prototype.hasOwnProperty.call(importAliases, exportName);
2487
+
2488
+ if (collidesWithGeneratedClass && importCollisionBehavior === "error") {
2489
+ throw createCustomPomImportCollisionError(exportName, requested);
2490
+ }
2491
+
2492
+ let localIdentifier = requested;
2493
+ if (collidesWithGeneratedClass && importCollisionBehavior === "alias") {
2494
+ const aliasBase = explicitAliasProvided ? requested : `${exportName}Custom`;
2495
+ localIdentifier = ensureUniqueIdentifier(aliasBase);
2496
+ }
2497
+ else {
2498
+ localIdentifier = ensureUniqueIdentifier(requested);
2499
+ }
2500
+
2501
+ const customFileAbs = path.join(customDirAbs, file);
2502
+ classIdentifierMap[exportName] = localIdentifier;
2503
+ importSpecifiersByClass[exportName] = {
2504
+ exportName,
2505
+ localIdentifier,
2506
+ absolutePath: customFileAbs,
2507
+ };
2508
+
2509
+ const customPomMethodSignatures = extractCustomPomMethodSignatures(fs.readFileSync(customFileAbs, "utf8"), exportName);
2510
+ if (customPomMethodSignatures.size > 0) {
2511
+ methodSignaturesByClass.set(exportName, customPomMethodSignatures);
2512
+ }
2513
+ }
2514
+
2515
+ return {
2516
+ classIdentifierMap,
2517
+ methodSignaturesByClass,
2518
+ availableClassIdentifiers: new Set(Object.values(classIdentifierMap)),
2519
+ importSpecifiersByClass,
2520
+ };
2521
+ }
2522
+
2523
+ function getComposedStubBody(
2524
+ targetClassName: string,
2525
+ availableClassNames: Set<string>,
2526
+ depsByClassName: Map<string, IComponentDependencies>,
2527
+ vueFilesPathMap: Map<string, string>,
2528
+ projectRoot: string,
2529
+ ) {
2530
+ const filePath = resolveVueSourcePath(targetClassName, vueFilesPathMap, projectRoot);
2531
+ if (!filePath)
2532
+ return undefined;
2533
+
2534
+ let source = "";
2535
+ try {
2536
+ source = fs.readFileSync(filePath, "utf8");
2537
+ }
2538
+ catch {
2539
+ return undefined;
2540
+ }
2541
+
2542
+ const tags = getComponentClassNamesFromVueSource(source);
2543
+ const childClassNames = Array.from(
2544
+ new Set(
2545
+ tags
2546
+ .filter(name => availableClassNames.has(name))
2547
+ .filter(name => name !== targetClassName),
2548
+ ),
2549
+ ).sort((a, b) => a.localeCompare(b));
2550
+
2551
+ if (!childClassNames.length)
2552
+ return undefined;
2553
+
2554
+ const methodToChildren = new Map<string, Array<{ child: string; params: string; argNames: string[] }>>();
2555
+ for (const child of childClassNames) {
2556
+ const childDeps = depsByClassName.get(child);
2557
+ const methods = childDeps?.generatedMethods;
2558
+ if (!methods)
2559
+ continue;
2560
+
2561
+ for (const [name, sig] of methods.entries()) {
2562
+ if (!sig)
2563
+ continue;
2564
+ const list = methodToChildren.get(name) ?? [];
2565
+ list.push({ child, params: sig.params, argNames: sig.argNames });
2566
+ methodToChildren.set(name, list);
2567
+ }
2568
+ }
2569
+
2570
+ const passthroughMembers: TypeScriptClassMember[] = [];
2571
+ for (const [methodName, candidatesForMethod] of methodToChildren.entries()) {
2572
+ if (candidatesForMethod.length !== 1 || methodName === "constructor")
2573
+ continue;
2574
+
2575
+ const { child, params, argNames } = candidatesForMethod[0];
2576
+ const callArgs = argNames.join(", ");
2577
+
2578
+ passthroughMembers.push(createClassMethod({
2579
+ name: methodName,
2580
+ isAsync: true,
2581
+ parameters: parseParameterSignatures(params),
2582
+ statements: [
2583
+ `return await this.${child}.${methodName}(${callArgs});`,
2584
+ ],
2585
+ }));
2586
+ }
2587
+
2588
+ return {
2589
+ childClassNames,
2590
+ members: [
2591
+ ...childClassNames.map(childClassName =>
2592
+ createClassProperty({
2593
+ name: childClassName,
2594
+ type: childClassName,
2595
+ })),
2596
+ createClassConstructor({
2597
+ parameters: [{ name: "page", type: "PwPage" }],
2598
+ statements: (writer) => {
2599
+ writer.writeLine("super(page);");
2600
+ for (const childClassName of childClassNames) {
2601
+ writer.writeLine(`this.${childClassName} = new ${childClassName}(page);`);
2602
+ }
2603
+ },
2604
+ }),
2605
+ ...passthroughMembers,
2606
+ ],
2607
+ };
2608
+ }
2609
+
1631
2610
  async function generateAggregatedFiles(
1632
2611
  componentHierarchyMap: Map<string, IComponentDependencies>,
1633
2612
  vueFilesPathMap: Map<string, string>,
@@ -1653,7 +2632,6 @@ async function generateAggregatedFiles(
1653
2632
  const components = entries.filter(([, d]) => !d.isView);
1654
2633
 
1655
2634
  const makeAggregatedContent = (
1656
- header: string,
1657
2635
  outputDir: string,
1658
2636
  items: Array<[string, IComponentDependencies]>,
1659
2637
  ) => {
@@ -1680,111 +2658,23 @@ async function generateAggregatedFiles(
1680
2658
  imports.push(`export * from "${runtimeClassGenRel}/Pointer";`);
1681
2659
  imports.push(`export * from "${runtimeClassGenRel}/BasePage";`);
1682
2660
 
1683
- // Handwritten POM helpers for complicated/third-party widgets.
1684
- // Convention: place them in `tests/playwright/pom/custom/*.ts`.
1685
- // Import them rather than inlining so TypeScript can typecheck them.
1686
- const addCustomPomImports = () => {
1687
- // Some custom POM helpers intentionally share names with generated component POMs
1688
- // (e.g. Toggle.vue -> generated class `Toggle`). Import with aliases to avoid
1689
- // merged-declaration conflicts in the aggregated output.
1690
- const importAliases: Record<string, string> = {
1691
- Toggle: "ToggleWidget",
1692
- Checkbox: "CheckboxWidget",
1693
- ...(options.customPomImportAliases),
1694
- };
1695
- const importCollisionBehavior = options.customPomImportNameCollisionBehavior ?? "error";
1696
-
1697
- const reservedIdentifiers = new Set<string>([
1698
- "PwLocator",
1699
- "PwPage",
1700
- "BasePage",
1701
- "Fluent",
1702
- ...generatedClassNames,
1703
- ]);
1704
- const usedImportIdentifiers = new Set<string>();
1705
- const customPomClassIdentifierMap: Record<string, string> = {};
1706
- const customPomMethodSignaturesByClass = new Map<string, CustomPomMethodSignatureMap>();
1707
-
1708
- const ensureUniqueIdentifier = (base: string) => {
1709
- let candidate = base;
1710
- let i = 2;
1711
- while (reservedIdentifiers.has(candidate) || usedImportIdentifiers.has(candidate)) {
1712
- candidate = `${base}${i}`;
1713
- i++;
1714
- }
1715
- usedImportIdentifiers.add(candidate);
1716
- return candidate;
1717
- };
1718
-
1719
- const customDirRelOrAbs = options.customPomDir ?? "tests/playwright/pom/custom";
1720
- const customDirAbs = path.isAbsolute(customDirRelOrAbs)
1721
- ? customDirRelOrAbs
1722
- : path.resolve(projectRoot, customDirRelOrAbs);
1723
-
1724
- if (!fs.existsSync(customDirAbs)) {
1725
- return {
1726
- classIdentifierMap: customPomClassIdentifierMap,
1727
- methodSignaturesByClass: customPomMethodSignaturesByClass,
1728
- };
1729
- }
1730
-
1731
- const files = fs.readdirSync(customDirAbs)
1732
- .filter(f => f.endsWith(".ts"))
1733
- .sort((a, b) => a.localeCompare(b));
1734
-
1735
- for (const file of files) {
1736
- const exportName = file.replace(/\.ts$/i, "");
1737
- // In this repo, custom POMs are authored as `export class <Name> { ... }`.
1738
- // Import by the basename, which matches the class name convention.
1739
- const requested = importAliases[exportName] ?? exportName;
1740
- const collidesWithGeneratedClass = generatedClassNames.has(requested);
1741
- const explicitAliasProvided = Object.prototype.hasOwnProperty.call(importAliases, exportName);
1742
-
1743
- if (collidesWithGeneratedClass && importCollisionBehavior === "error") {
1744
- throw new Error(
1745
- `[vue-pom-generator] Custom POM import name collision detected for "${exportName}".\n`
1746
- + `The identifier "${requested}" conflicts with a generated POM class.\n`
1747
- + `Fix by setting generation.playwright.customPoms.importAliases["${exportName}"] to a unique name, `
1748
- + `or set generation.playwright.customPoms.importNameCollisionBehavior = "alias" to auto-alias collisions.`,
1749
- );
1750
- }
1751
-
1752
- let localIdentifier = requested;
1753
- if (collidesWithGeneratedClass && importCollisionBehavior === "alias") {
1754
- const aliasBase = explicitAliasProvided ? requested : `${exportName}Custom`;
1755
- localIdentifier = ensureUniqueIdentifier(aliasBase);
1756
- }
1757
- else {
1758
- localIdentifier = ensureUniqueIdentifier(requested);
1759
- }
1760
-
1761
- const customFileAbs = path.join(customDirAbs, file);
1762
- customPomClassIdentifierMap[exportName] = localIdentifier;
1763
- const customPomMethodSignatures = extractCustomPomMethodSignatures(fs.readFileSync(customFileAbs, "utf8"), exportName);
1764
- if (customPomMethodSignatures.size > 0) {
1765
- customPomMethodSignaturesByClass.set(exportName, customPomMethodSignatures);
1766
- }
1767
-
1768
- const fromOutputDir = outputDir;
1769
- const importPath = stripExtension(toPosixRelativePath(fromOutputDir, customFileAbs));
1770
- if (localIdentifier !== exportName) {
1771
- imports.push(`import { ${exportName} as ${localIdentifier} } from "${importPath}";`);
1772
- }
1773
- else {
1774
- imports.push(`import { ${exportName} } from "${importPath}";`);
1775
- }
2661
+ const customPomImportResolution = resolveCustomPomImportResolution(generatedClassNames, projectRoot, {
2662
+ customPomDir: options.customPomDir,
2663
+ customPomImportAliases: options.customPomImportAliases,
2664
+ customPomImportNameCollisionBehavior: options.customPomImportNameCollisionBehavior,
2665
+ });
2666
+ const customPomClassIdentifierMap = customPomImportResolution.classIdentifierMap;
2667
+ const customPomMethodSignaturesByClass = customPomImportResolution.methodSignaturesByClass;
2668
+ const customPomAvailableClassIdentifiers = customPomImportResolution.availableClassIdentifiers;
2669
+
2670
+ for (const importSpecifier of Object.values(customPomImportResolution.importSpecifiersByClass).sort((left, right) => left.exportName.localeCompare(right.exportName))) {
2671
+ const importPath = stripExtension(toPosixRelativePath(outputDir, importSpecifier.absolutePath));
2672
+ if (importSpecifier.localIdentifier !== importSpecifier.exportName) {
2673
+ imports.push(`import { ${importSpecifier.exportName} as ${importSpecifier.localIdentifier} } from "${importPath}";`);
2674
+ continue;
1776
2675
  }
1777
-
1778
- return {
1779
- classIdentifierMap: customPomClassIdentifierMap,
1780
- methodSignaturesByClass: customPomMethodSignaturesByClass,
1781
- };
1782
- };
1783
-
1784
- const customPomImportResolution = addCustomPomImports();
1785
- const customPomClassIdentifierMap = customPomImportResolution?.classIdentifierMap ?? {};
1786
- const customPomMethodSignaturesByClass = customPomImportResolution?.methodSignaturesByClass ?? new Map<string, CustomPomMethodSignatureMap>();
1787
- const customPomAvailableClassIdentifiers = new Set(Object.values(customPomClassIdentifierMap));
2676
+ imports.push(`import { ${importSpecifier.exportName} } from "${importPath}";`);
2677
+ }
1788
2678
 
1789
2679
  // Collect any navigation return types referenced by generated methods so we can emit
1790
2680
  // stub classes when the destination view has no generated test ids (and therefore no
@@ -1806,184 +2696,21 @@ async function generateAggregatedFiles(
1806
2696
 
1807
2697
  const depsByClassName = new Map<string, IComponentDependencies>(entries);
1808
2698
 
1809
- const scanPascalCaseTags = (template: string) => {
1810
- // Extracts tag names like <TenantDetailsEditForm ...> without regex.
1811
- // We only care about PascalCase component tags.
1812
- const names: string[] = [];
1813
- const len = template.length;
1814
- let i = 0;
1815
- while (i < len) {
1816
- const ch = template[i];
1817
- if (ch !== "<") {
1818
- i++;
1819
- continue;
1820
- }
1821
-
1822
- i++; // consume '<'
1823
- if (i >= len)
1824
- break;
1825
-
1826
- // Skip closing tags and directives/comments
1827
- if (template[i] === "/" || template[i] === "!" || template[i] === "?") {
1828
- i++;
1829
- continue;
1830
- }
1831
-
1832
- // Skip whitespace
1833
- while (i < len && (template[i] === " " || template[i] === "\n" || template[i] === "\t" || template[i] === "\r")) i++;
1834
- if (i >= len)
1835
- break;
1836
-
1837
- const first = template[i];
1838
- // Only PascalCase (starts with A-Z)
1839
- if (first < "A" || first > "Z") {
1840
- continue;
1841
- }
1842
-
1843
- const start = i;
1844
- i++;
1845
- while (i < len) {
1846
- const c = template[i];
1847
- const isLetter = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z");
1848
- const isDigit = c >= "0" && c <= "9";
1849
- const isUnderscore = c === "_";
1850
- if (isLetter || isDigit || isUnderscore) {
1851
- i++;
1852
- continue;
1853
- }
1854
- break;
1855
- }
1856
- const name = template.slice(start, i);
1857
- if (name)
1858
- names.push(name);
1859
- }
1860
- return Array.from(new Set(names));
1861
- };
1862
-
1863
- const getComposedStubBody = (targetClassName: string) => {
1864
- const mapped = vueFilesPathMap.get(targetClassName);
1865
- const candidates = [
1866
- mapped,
1867
- path.join(projectRoot, "src", "views", `${targetClassName}.vue`),
1868
- path.join(projectRoot, "src", "components", `${targetClassName}.vue`),
1869
- ].filter((p): p is string => typeof p === "string" && p.length > 0);
1870
-
1871
- const filePath = candidates.find(p => fs.existsSync(p));
1872
- if (!filePath)
1873
- return undefined;
1874
-
1875
- // Heuristic: scan the SFC template for PascalCase tags. If we have generated
1876
- // POM classes for those components, include them as composed children.
1877
- let source = "";
1878
- try {
1879
- source = fs.readFileSync(filePath, "utf8");
1880
- }
1881
- catch {
1882
- return undefined;
1883
- }
1884
-
1885
- const templateOpen = source.indexOf("<template");
1886
- const templateClose = source.lastIndexOf("</template>");
1887
- if (templateOpen === -1 || templateClose === -1 || templateClose <= templateOpen)
1888
- return undefined;
1889
-
1890
- const afterOpenTag = source.indexOf(">", templateOpen);
1891
- if (afterOpenTag === -1 || afterOpenTag >= templateClose)
1892
- return undefined;
1893
-
1894
- const template = source.slice(afterOpenTag + 1, templateClose);
1895
- if (!template)
1896
- return undefined;
1897
-
1898
- const tags = scanPascalCaseTags(template);
1899
- const childClassNames = Array.from(
1900
- new Set(
1901
- tags
1902
- .filter(name => availableClassNames.has(name))
1903
- .filter(name => name !== targetClassName),
1904
- ),
1905
- ).sort((a, b) => a.localeCompare(b));
1906
-
1907
- if (!childClassNames.length)
1908
- return undefined;
1909
-
1910
- // Build passthrough methods from stub -> child component when the method is unambiguous.
1911
- // This enables ergonomics like:
1912
- // await tenantListPage.goToNewTenant().typeTenantName(...)
1913
- // without forcing the test to reference `.TenantDetailsEditForm`.
1914
- const methodToChildren = new Map<string, Array<{ child: string; params: string; argNames: string[] }>>();
1915
- for (const child of childClassNames) {
1916
- const childDeps = depsByClassName.get(child);
1917
- const methods = childDeps?.generatedMethods;
1918
- if (!methods)
1919
- continue;
1920
-
1921
- for (const [name, sig] of methods.entries()) {
1922
- if (!sig)
1923
- continue; // ambiguous
1924
- const list = methodToChildren.get(name) ?? [];
1925
- list.push({ child, params: sig.params, argNames: sig.argNames });
1926
- methodToChildren.set(name, list);
1927
- }
1928
- }
1929
-
1930
- const passthroughLines: string[] = [];
1931
- for (const [methodName, candidatesForMethod] of methodToChildren.entries()) {
1932
- if (candidatesForMethod.length !== 1)
1933
- continue;
1934
-
1935
- // Avoid creating pass-throughs for internal-ish helpers.
1936
- if (methodName === "constructor")
1937
- continue;
1938
-
1939
- const { child, params, argNames } = candidatesForMethod[0];
1940
- const callArgs = argNames.join(", ");
1941
-
1942
- passthroughLines.push(
1943
- "",
1944
- ` async ${methodName}(${params}) {`,
1945
- ` return await this.${child}.${methodName}(${callArgs});`,
1946
- " }",
1947
- );
1948
- }
1949
-
1950
- return {
1951
- childClassNames,
1952
- lines: [
1953
- ...childClassNames.map(c => ` ${c}: ${c};`),
1954
- "",
1955
- " constructor(page: PwPage) {",
1956
- " super(page);",
1957
- ...childClassNames.map(c => ` this.${c} = new ${c}(page);`),
1958
- " }",
1959
- ...passthroughLines,
1960
- ],
1961
- };
1962
- };
1963
-
1964
2699
  const stubs = stubTargets.map(t =>
1965
2700
  (() => {
1966
- const composed = getComposedStubBody(t);
1967
- const body = composed?.lines ?? [
1968
- " constructor(page: PwPage) {",
1969
- " super(page);",
1970
- " }",
1971
- ];
1972
-
1973
- return [
1974
- "/**\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 */",
1975
- `export class ${t} extends BasePage {`,
1976
- ...body,
1977
- "}",
1978
- ].join("\n");
2701
+ const composed = getComposedStubBody(t, availableClassNames, depsByClassName, vueFilesPathMap, projectRoot);
2702
+ return {
2703
+ className: t,
2704
+ members: composed?.members ?? getDefaultStubMembers(),
2705
+ isStub: true as const,
2706
+ };
1979
2707
  })(),
1980
2708
  );
1981
2709
 
1982
- const classes = items.map(([name, deps]) =>
1983
- generateViewObjectModelContent(name, deps, componentHierarchyMap, vueFilesPathMap, basePageClassPath, {
2710
+ const classes = items.map(([name, deps]) => {
2711
+ const prepared = prepareViewObjectModelClass(name, deps, componentHierarchyMap, {
1984
2712
  outputDir,
1985
- aggregated: true,
1986
-
2713
+ outputStructure: "aggregated",
1987
2714
  customPomAttachments: options.customPomAttachments ?? [],
1988
2715
  customPomClassIdentifierMap,
1989
2716
  customPomAvailableClassIdentifiers,
@@ -1991,78 +2718,78 @@ async function generateAggregatedFiles(
1991
2718
  testIdAttribute: options.testIdAttribute,
1992
2719
  vueRouterFluentChaining: options.vueRouterFluentChaining,
1993
2720
  routeMetaByComponent: options.routeMetaByComponent,
1994
- }),
1995
- );
1996
-
1997
- const baseContent = [
1998
- header,
1999
- ...imports,
2000
- ...classes,
2001
- ...(stubs.length ? ["", ...stubs] : []),
2002
- ].filter(Boolean).join("\n\n");
2721
+ });
2722
+ const sourceRel = toPosixRelativePath(outputDir, deps.filePath);
2723
+ const kind = deps.isView ? "Page" : "Component";
2724
+ return {
2725
+ className: prepared.className,
2726
+ doc: `/** ${kind} POM: ${name} (source: ${sourceRel}) */`,
2727
+ members: prepared.members,
2728
+ isStub: false as const,
2729
+ };
2730
+ });
2003
2731
 
2004
- return baseContent;
2005
- };
2732
+ const prefixText = buildFilePrefix({
2733
+ referenceLib: "es2015",
2734
+ eslintDisableSortImports: true,
2735
+ commentLines: [
2736
+ "Aggregated generated POMs",
2737
+ "DO NOT MODIFY BY HAND",
2738
+ "",
2739
+ "This file is auto-generated by vue-pom-generator.",
2740
+ "Changes should be made in the generator/template, not in the generated output.",
2741
+ ],
2742
+ });
2006
2743
 
2007
- const base = ensureDir(outDir);
2008
- const outputFile = path.join(base, "page-object-models.g.ts");
2009
- const header = `/// <reference lib="es2015" />\n${eslintSuppressionHeader}/**\n * Aggregated generated POMs\n${AUTO_GENERATED_COMMENT}`;
2010
- const content = makeAggregatedContent(header, path.dirname(outputFile), [...views, ...components]);
2744
+ return renderSourceFile("page-object-models.g.ts", (sourceFile) => {
2745
+ for (const line of imports) {
2746
+ sourceFile.addStatements(line);
2747
+ }
2011
2748
 
2012
- const indexFile = path.join(base, "index.ts");
2013
- const indexContent = `${eslintSuppressionHeader}/**\n * POM exports\n${AUTO_GENERATED_COMMENT}\n\nexport * from "./page-object-models.g";\n`;
2749
+ for (const entry of [...classes, ...stubs]) {
2750
+ if (entry.isStub) {
2751
+ sourceFile.addStatements(buildCommentBlock([
2752
+ "Stub POM generated because it is referenced as a navigation target but",
2753
+ "did not have any generated test ids in this build.",
2754
+ ]).trimEnd());
2755
+ }
2756
+ else {
2757
+ sourceFile.addStatements(entry.doc);
2758
+ }
2014
2759
 
2015
- const runtimeDirAbs = path.join(base, "_pom-runtime");
2016
- const runtimeClassGenAbs = path.join(runtimeDirAbs, "class-generation");
2760
+ const classDeclaration = sourceFile.addClass({
2761
+ name: entry.className,
2762
+ isExported: true,
2763
+ extends: "BasePage",
2764
+ });
2017
2765
 
2018
- const readText = (absPath: string, description: string) => {
2019
- try {
2020
- return fs.readFileSync(absPath, "utf8");
2021
- }
2022
- catch {
2023
- throw new Error(`Failed to read ${description} at ${absPath}`);
2024
- }
2766
+ for (const member of entry.members) {
2767
+ addClassMember(classDeclaration, member);
2768
+ }
2769
+ }
2770
+ }, { prefixText });
2025
2771
  };
2026
2772
 
2027
- // Copy runtime dependencies into the output folder so the aggregated POM file can
2028
- // import them without relying on workspace package resolution.
2029
- // Resolve paths to bundled runtime files. Mirror the pattern in support-plugins.ts:
2030
- // try fileURLToPath(new URL(..., import.meta.url)) (works in ESM where import.meta.url
2031
- // is a proper file:// URL), then fall back to __dirname (available in the CJS bundle
2032
- // via Node's module wrapper). The fallback is needed because ensureDomShim() in
2033
- // router-introspection sets globalThis.document via JSDOM (url "https://example.test/"),
2034
- // after which Rollup's CJS shim for import.meta.url resolves to document.baseURI
2035
- // ("https://example.test/index.cjs") — not a file:// URL — causing fileURLToPath to throw.
2036
- const resolvePluginAsset = (relative: string): string => {
2037
- try {
2038
- return fileURLToPath(new URL(relative, import.meta.url));
2039
- }
2040
- catch {
2041
- return path.resolve(__dirname, relative);
2042
- }
2043
- };
2044
- const clickInstrumentationAbs = resolvePluginAsset("../click-instrumentation.ts");
2045
- const pointerAbs = resolvePluginAsset("../class-generation/Pointer.ts");
2046
- const playwrightTypesAbs = resolvePluginAsset("../class-generation/playwright-types.ts");
2773
+ const base = ensureDir(outDir);
2774
+ const outputFile = path.join(base, "page-object-models.g.ts");
2775
+ const content = makeAggregatedContent(path.dirname(outputFile), [...views, ...components]);
2047
2776
 
2048
- const runtimeFiles: Array<{ filePath: string; content: string }> = [
2049
- {
2050
- filePath: path.join(runtimeDirAbs, "click-instrumentation.ts"),
2051
- content: readText(clickInstrumentationAbs, "click-instrumentation.ts"),
2052
- },
2053
- {
2054
- filePath: path.join(runtimeClassGenAbs, "Pointer.ts"),
2055
- content: readText(pointerAbs, "Pointer.ts"),
2056
- },
2057
- {
2058
- filePath: path.join(runtimeClassGenAbs, "playwright-types.ts"),
2059
- content: readText(playwrightTypesAbs, "playwright-types.ts"),
2060
- },
2061
- {
2062
- filePath: path.join(runtimeClassGenAbs, "BasePage.ts"),
2063
- content: readText(basePageClassPath, "BasePage.ts"),
2064
- },
2065
- ];
2777
+ const indexFile = path.join(base, "index.ts");
2778
+ const indexContent = renderSourceFile("index.ts", (sourceFile) => {
2779
+ addExportAll(sourceFile, "./page-object-models.g");
2780
+ }, {
2781
+ prefixText: buildFilePrefix({
2782
+ eslintDisableSortImports: true,
2783
+ commentLines: [
2784
+ "POM exports",
2785
+ "DO NOT MODIFY BY HAND",
2786
+ "",
2787
+ "This file is auto-generated by vue-pom-generator.",
2788
+ "Changes should be made in the generator/template, not in the generated output.",
2789
+ ],
2790
+ }),
2791
+ });
2792
+ const runtimeFiles = buildRuntimeGeneratedFiles(base, basePageClassPath);
2066
2793
 
2067
2794
  return [
2068
2795
  { filePath: outputFile, content },
@@ -2207,23 +2934,33 @@ function getComponentInstances(
2207
2934
  attachmentsForThisView: Array<{ className: string; propertyName: string }> = [],
2208
2935
  widgetInstances: WidgetInstance[] = [],
2209
2936
  ) {
2210
- let content = "\n";
2937
+ const declarations: TypeScriptClassMember[] = [];
2211
2938
 
2212
2939
  for (const a of attachmentsForThisView) {
2213
- content += ` ${a.propertyName}: ${a.className};\n`;
2940
+ declarations.push(createClassProperty({
2941
+ name: a.propertyName,
2942
+ type: a.className,
2943
+ }));
2214
2944
  }
2215
2945
 
2216
2946
  for (const w of widgetInstances) {
2217
- content += ` ${w.propertyName}: ${w.className};\n`;
2947
+ declarations.push(createClassProperty({
2948
+ name: w.propertyName,
2949
+ type: w.className,
2950
+ }));
2218
2951
  }
2219
2952
 
2220
2953
  childrenComponent.forEach((child) => {
2221
2954
  if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
2222
2955
  const childName = child.split(".vue")[0];
2223
- content += ` ${childName}: ${childName};\n`;
2956
+ declarations.push(createClassProperty({
2957
+ name: childName,
2958
+ type: childName,
2959
+ }));
2224
2960
  }
2225
2961
  });
2226
- return `${content}\n`;
2962
+
2963
+ return declarations;
2227
2964
  }
2228
2965
 
2229
2966
  function getConstructor(
@@ -2233,24 +2970,26 @@ function getConstructor(
2233
2970
  widgetInstances: WidgetInstance[] = [],
2234
2971
  options?: { testIdAttribute?: string },
2235
2972
  ) {
2236
- let content = " constructor(page: PwPage) {\n";
2237
2973
  const attr = (options?.testIdAttribute ?? "data-testid").trim() || "data-testid";
2238
- content += ` super(page, { testIdAttribute: ${JSON.stringify(attr)} });\n`;
2974
+ return createClassConstructor({
2975
+ parameters: [{ name: "page", type: "PwPage" }],
2976
+ statements: (writer) => {
2977
+ writer.writeLine(`super(page, { testIdAttribute: ${JSON.stringify(attr)} });`);
2239
2978
 
2240
- for (const a of attachmentsForThisView) {
2241
- content += ` this.${a.propertyName} = new ${a.className}(page, this);\n`;
2242
- }
2979
+ for (const a of attachmentsForThisView) {
2980
+ writer.writeLine(`this.${a.propertyName} = new ${a.className}(page, this);`);
2981
+ }
2243
2982
 
2244
- for (const w of widgetInstances) {
2245
- content += ` this.${w.propertyName} = new ${w.className}(page, ${JSON.stringify(w.testId)});\n`;
2246
- }
2983
+ for (const w of widgetInstances) {
2984
+ writer.writeLine(`this.${w.propertyName} = new ${w.className}(page, ${JSON.stringify(w.testId)});`);
2985
+ }
2247
2986
 
2248
- childrenComponent.forEach((child) => {
2249
- if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
2250
- const childName = child.split(".vue")[0];
2251
- content += ` this.${childName} = new ${childName}(page);\n`;
2252
- }
2987
+ childrenComponent.forEach((child) => {
2988
+ if (componentHierarchyMap.has(child) && componentHierarchyMap.get(child)?.dataTestIdSet.size) {
2989
+ const childName = child.split(".vue")[0];
2990
+ writer.writeLine(`this.${childName} = new ${childName}(page);`);
2991
+ }
2992
+ });
2993
+ },
2253
2994
  });
2254
- content += " }";
2255
- return `${content}\n`;
2256
2995
  }