@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.
- package/README.md +30 -11
- package/RELEASE_NOTES.md +20 -33
- package/class-generation/Pointer.ts +69 -10
- package/class-generation/index.ts +1421 -682
- package/dist/class-generation/Pointer.d.ts +1 -1
- package/dist/class-generation/Pointer.d.ts.map +1 -1
- package/dist/class-generation/index.d.ts +8 -0
- package/dist/class-generation/index.d.ts.map +1 -1
- package/dist/index.cjs +1437 -689
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1440 -692
- package/dist/index.mjs.map +1 -1
- package/dist/manifest-generator.d.ts.map +1 -1
- package/dist/method-generation.d.ts +2 -0
- package/dist/method-generation.d.ts.map +1 -1
- package/dist/plugin/create-vue-pom-generator-plugins.d.ts.map +1 -1
- package/dist/plugin/support/build-plugin.d.ts +2 -1
- package/dist/plugin/support/build-plugin.d.ts.map +1 -1
- package/dist/plugin/support/dev-plugin.d.ts +2 -1
- package/dist/plugin/support/dev-plugin.d.ts.map +1 -1
- package/dist/plugin/support-plugins.d.ts +2 -1
- package/dist/plugin/support-plugins.d.ts.map +1 -1
- package/dist/plugin/types.d.ts +15 -7
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/tests/fixtures/generated-tsc/BasePage.full.d.ts +19 -0
- package/dist/tests/fixtures/generated-tsc/BasePage.full.d.ts.map +1 -0
- package/dist/tests/fixtures/generated-tsc/BasePage.minimal.d.ts +8 -0
- package/dist/tests/fixtures/generated-tsc/BasePage.minimal.d.ts.map +1 -0
- package/dist/tests/fixtures/generated-tsc/Pointer.d.ts +6 -0
- package/dist/tests/fixtures/generated-tsc/Pointer.d.ts.map +1 -0
- package/dist/typescript-codegen.d.ts +34 -0
- package/dist/typescript-codegen.d.ts.map +1 -0
- 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 {
|
|
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):
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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):
|
|
442
|
+
function generateGoToSelfMethod(componentName: string): TypeScriptClassMember[] {
|
|
155
443
|
return [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
575
|
+
function generateMethodMembersFromPom(primary: PomPrimarySpec, targetPageObjectModelClass?: string): TypeScriptClassMember[] {
|
|
272
576
|
if (primary.emitPrimary === false) {
|
|
273
|
-
return
|
|
577
|
+
return [];
|
|
274
578
|
}
|
|
275
579
|
|
|
276
|
-
return
|
|
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):
|
|
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
|
-
|
|
629
|
+
const members: TypeScriptClassMember[] = [];
|
|
326
630
|
for (const { pom, target } of primarySpecs) {
|
|
327
|
-
|
|
631
|
+
members.push(...generateMethodMembersFromPom(pom, target));
|
|
328
632
|
}
|
|
329
633
|
|
|
330
634
|
for (const extra of extras) {
|
|
331
|
-
|
|
635
|
+
members.push(...generateExtraClickMethodMembers(extra));
|
|
332
636
|
}
|
|
333
637
|
|
|
334
|
-
return
|
|
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
|
|
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 =
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
.
|
|
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
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
content += generateGoToSelfMethod(className);
|
|
1880
|
+
members.push(...generateRouteProperty(routeMeta));
|
|
1881
|
+
members.push(...generateGoToSelfMethod(className));
|
|
1383
1882
|
}
|
|
1384
1883
|
|
|
1385
|
-
|
|
1884
|
+
members.push(...generateMethodsContentForDependencies(dependencies));
|
|
1386
1885
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1886
|
+
return {
|
|
1887
|
+
className,
|
|
1888
|
+
componentRefsForInstances,
|
|
1889
|
+
attachmentsForThisClass,
|
|
1890
|
+
widgetInstances,
|
|
1891
|
+
isView,
|
|
1892
|
+
members,
|
|
1893
|
+
};
|
|
1389
1894
|
}
|
|
1390
1895
|
|
|
1391
|
-
function
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
childrenComponentSet: Set<string>,
|
|
1896
|
+
function generateViewObjectModelContent(
|
|
1897
|
+
componentName: string,
|
|
1898
|
+
dependencies: IComponentDependencies,
|
|
1395
1899
|
componentHierarchyMap: Map<string, IComponentDependencies>,
|
|
1396
|
-
|
|
1900
|
+
_vueFilesPathMap: Map<string, string>,
|
|
1901
|
+
basePageClassPath: string,
|
|
1902
|
+
options: GenerateContentOptions = {},
|
|
1397
1903
|
) {
|
|
1398
|
-
const
|
|
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
|
-
|
|
1401
|
-
|
|
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
|
-
|
|
1404
|
-
|
|
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
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
-
|
|
2710
|
+
const classes = items.map(([name, deps]) => {
|
|
2711
|
+
const prepared = prepareViewObjectModelClass(name, deps, componentHierarchyMap, {
|
|
1984
2712
|
outputDir,
|
|
1985
|
-
|
|
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
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2744
|
+
return renderSourceFile("page-object-models.g.ts", (sourceFile) => {
|
|
2745
|
+
for (const line of imports) {
|
|
2746
|
+
sourceFile.addStatements(line);
|
|
2747
|
+
}
|
|
2011
2748
|
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2760
|
+
const classDeclaration = sourceFile.addClass({
|
|
2761
|
+
name: entry.className,
|
|
2762
|
+
isExported: true,
|
|
2763
|
+
extends: "BasePage",
|
|
2764
|
+
});
|
|
2017
2765
|
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
2937
|
+
const declarations: TypeScriptClassMember[] = [];
|
|
2211
2938
|
|
|
2212
2939
|
for (const a of attachmentsForThisView) {
|
|
2213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2956
|
+
declarations.push(createClassProperty({
|
|
2957
|
+
name: childName,
|
|
2958
|
+
type: childName,
|
|
2959
|
+
}));
|
|
2224
2960
|
}
|
|
2225
2961
|
});
|
|
2226
|
-
|
|
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
|
-
|
|
2974
|
+
return createClassConstructor({
|
|
2975
|
+
parameters: [{ name: "page", type: "PwPage" }],
|
|
2976
|
+
statements: (writer) => {
|
|
2977
|
+
writer.writeLine(`super(page, { testIdAttribute: ${JSON.stringify(attr)} });`);
|
|
2239
2978
|
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2979
|
+
for (const a of attachmentsForThisView) {
|
|
2980
|
+
writer.writeLine(`this.${a.propertyName} = new ${a.className}(page, this);`);
|
|
2981
|
+
}
|
|
2243
2982
|
|
|
2244
|
-
|
|
2245
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
}
|