@immense/vue-pom-generator 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +37 -0
- package/README.md +171 -0
- package/class-generation/BasePage.ts +569 -0
- package/class-generation/Pointer.ts +124 -0
- package/class-generation/index.ts +1691 -0
- package/dist/index.cjs +5163 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +5124 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +76 -0
- package/sequence-diagram.md +200 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import type { Locator as PwLocator, Page as PwPage } from "@playwright/test";
|
|
2
|
+
import { TESTID_CLICK_EVENT_NAME, TESTID_CLICK_EVENT_STRICT_FLAG } from "../click-instrumentation";
|
|
3
|
+
import type { TestIdClickEventDetail } from "../click-instrumentation";
|
|
4
|
+
import { Pointer } from "./Pointer";
|
|
5
|
+
import type { AfterPointerClickInfo } from "./Pointer";
|
|
6
|
+
|
|
7
|
+
// Click instrumentation is a core contract for generated POMs.
|
|
8
|
+
const REQUIRE_CLICK_EVENT = true;
|
|
9
|
+
|
|
10
|
+
// Keep logging off by default.
|
|
11
|
+
const CLICK_EVENT_DEBUG = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A chainable, thenable wrapper around a page object.
|
|
15
|
+
*
|
|
16
|
+
* This exists to enable fluent syntax for navigation-generated methods, e.g.:
|
|
17
|
+
* await tenantListPage.goToNewTenant().typeTenantName("Acme")
|
|
18
|
+
*
|
|
19
|
+
* The wrapper is PromiseLike<T>, so `await` returns the underlying page object once
|
|
20
|
+
* the queued navigation/actions complete.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Deep fluent wrapper that preserves the original property surface while making
|
|
24
|
+
* all methods chain back to the root fluent type.
|
|
25
|
+
*/
|
|
26
|
+
type DeepFluent<T, TRoot extends object> = {
|
|
27
|
+
[K in keyof T]: T[K] extends (...args: infer A) => infer _R
|
|
28
|
+
? K extends "getObjectId"
|
|
29
|
+
? (...args: A) => ValueFluent<Awaited<_R>>
|
|
30
|
+
: K extends "getObjectIdAsInt"
|
|
31
|
+
? (...args: A) => ValueFluent<Awaited<_R>>
|
|
32
|
+
: (...args: A) => Fluent<TRoot>
|
|
33
|
+
: T[K] extends object
|
|
34
|
+
? DeepFluent<T[K], TRoot>
|
|
35
|
+
: T[K];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type DeepValueFluent<T> = {
|
|
39
|
+
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
|
40
|
+
? (...args: A) => ValueFluent<Awaited<R>>
|
|
41
|
+
: T[K] extends object
|
|
42
|
+
? DeepValueFluent<T[K]>
|
|
43
|
+
: T[K];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type Fluent<T extends object> = DeepFluent<T, T> & PromiseLike<T>;
|
|
47
|
+
|
|
48
|
+
export type ValueFluent<T> = DeepValueFluent<T> & PromiseLike<T>;
|
|
49
|
+
|
|
50
|
+
export class ObjectId {
|
|
51
|
+
private readonly raw: string;
|
|
52
|
+
|
|
53
|
+
public constructor(raw: string) {
|
|
54
|
+
if (!raw) {
|
|
55
|
+
throw new Error("ObjectId: raw value is empty");
|
|
56
|
+
}
|
|
57
|
+
this.raw = raw;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public toString(): string {
|
|
61
|
+
return this.raw;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public asInt(): number {
|
|
65
|
+
return this.AsInt();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public AsInt(): number {
|
|
69
|
+
// Only accept base-10 integer strings.
|
|
70
|
+
if (!/^\d+$/.test(this.raw)) {
|
|
71
|
+
throw new Error(`ObjectId.AsInt: '${this.raw}' is not a base-10 integer string`);
|
|
72
|
+
}
|
|
73
|
+
const parsed = Number.parseInt(this.raw, 10);
|
|
74
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
75
|
+
throw new TypeError(`ObjectId.AsInt: '${this.raw}' is not a safe integer`);
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Base Page Object Model class that provides common functionality
|
|
83
|
+
* for all component-specific Page Object Models
|
|
84
|
+
*/
|
|
85
|
+
export class BasePage {
|
|
86
|
+
protected readonly testIdAttribute: string;
|
|
87
|
+
|
|
88
|
+
private readonly pointer: InstanceType<typeof Pointer>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {Page} page - Playwright page object
|
|
92
|
+
*/
|
|
93
|
+
constructor(protected page: PwPage, options?: { testIdAttribute?: string }) {
|
|
94
|
+
this.testIdAttribute = (options?.testIdAttribute || "data-testid").trim() || "data-testid";
|
|
95
|
+
|
|
96
|
+
this.pointer = new Pointer(this.page, this.testIdAttribute);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async waitForTestIdClickEventAfter(testId: string, options?: { timeoutMs?: number }): Promise<void> {
|
|
100
|
+
const timeoutMs = options?.timeoutMs ?? 2_000;
|
|
101
|
+
const requireEvent = REQUIRE_CLICK_EVENT;
|
|
102
|
+
|
|
103
|
+
if (CLICK_EVENT_DEBUG) {
|
|
104
|
+
// This log is on the Node side (Playwright runner).
|
|
105
|
+
console.log(`[testid-click-event] waiting for '${testId}' after (timeout=${timeoutMs}ms, require=${requireEvent})`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// If the click triggers navigation, the JS context can be destroyed while waiting.
|
|
109
|
+
// In that scenario, the click already did its job; don't fail the test infra.
|
|
110
|
+
try {
|
|
111
|
+
await this.page.evaluate(
|
|
112
|
+
({ eventName, strictFlagName, expectedTestId, timeoutMs, requireEvent, debug }) => {
|
|
113
|
+
return new Promise<void>((resolve, reject) => {
|
|
114
|
+
const g = globalThis;
|
|
115
|
+
if (!g || typeof g.addEventListener !== "function") {
|
|
116
|
+
reject(new Error(`Click instrumentation not available (no addEventListener) for '${expectedTestId}'`));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Mark strict mode in the page so the injected click wrapper can
|
|
121
|
+
// fail fast (no fallback) when instrumentation is expected.
|
|
122
|
+
if (requireEvent) {
|
|
123
|
+
try {
|
|
124
|
+
type GlobalWithFlag = typeof globalThis & { [k: string]: boolean | undefined };
|
|
125
|
+
(g as GlobalWithFlag)[strictFlagName] = true;
|
|
126
|
+
}
|
|
127
|
+
catch { /* noop */ }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cleanup = (timer: ReturnType<typeof setTimeout>, onEvent: (evt: Event) => void) => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
try {
|
|
133
|
+
g.removeEventListener(eventName, onEvent);
|
|
134
|
+
}
|
|
135
|
+
catch { /* noop */ }
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let finished = false;
|
|
139
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
140
|
+
let onEvent: (evt: Event) => void;
|
|
141
|
+
|
|
142
|
+
const finishOk = () => {
|
|
143
|
+
if (finished) return;
|
|
144
|
+
finished = true;
|
|
145
|
+
cleanup(timer, onEvent);
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const finishErr = (err: Error) => {
|
|
150
|
+
if (finished) return;
|
|
151
|
+
finished = true;
|
|
152
|
+
cleanup(timer, onEvent);
|
|
153
|
+
reject(err);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
onEvent = (evt: Event) => {
|
|
157
|
+
const detail = (evt as CustomEvent<TestIdClickEventDetail>).detail;
|
|
158
|
+
if (!detail) return;
|
|
159
|
+
|
|
160
|
+
if (debug) {
|
|
161
|
+
console.log(`[testid-click-event][page] saw ${eventName} testId='${detail.testId}' phase='${detail.phase}'`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (detail.testId !== expectedTestId) return;
|
|
165
|
+
|
|
166
|
+
if (detail.phase === "error") {
|
|
167
|
+
finishErr(new Error(detail.err || `Click handler failed for ${expectedTestId}`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (detail.phase === "after") {
|
|
172
|
+
finishOk();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
timer = setTimeout(() => {
|
|
177
|
+
finishErr(new Error(`Timed out waiting for ${eventName} 'after' for '${expectedTestId}' (${timeoutMs}ms)`));
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (debug) {
|
|
182
|
+
console.log(`[testid-click-event][page] addEventListener(${eventName}) for '${expectedTestId}'`);
|
|
183
|
+
}
|
|
184
|
+
g.addEventListener(eventName, onEvent);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
finishErr(new Error(`Click instrumentation not available (addEventListener threw) for '${expectedTestId}'`));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
eventName: TESTID_CLICK_EVENT_NAME,
|
|
193
|
+
strictFlagName: TESTID_CLICK_EVENT_STRICT_FLAG,
|
|
194
|
+
expectedTestId: testId,
|
|
195
|
+
timeoutMs,
|
|
196
|
+
requireEvent,
|
|
197
|
+
debug: CLICK_EVENT_DEBUG,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
203
|
+
if (msg.includes("Execution context was destroyed") || msg.includes("Target closed")) {
|
|
204
|
+
if (CLICK_EVENT_DEBUG) {
|
|
205
|
+
console.log(`[testid-click-event] context destroyed while waiting for '${testId}' (likely navigation)`);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
protected selectorForTestId(testId: string): string {
|
|
214
|
+
return `[${this.testIdAttribute}="${testId}"]`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected locatorByTestId(testId: string): PwLocator {
|
|
218
|
+
return this.page.locator(this.selectorForTestId(testId));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Creates an indexable proxy for keyed elements so generated POMs can expose
|
|
223
|
+
* ergonomic accessors like:
|
|
224
|
+
* expect(page.SaveButton["MyKey"]).toBeVisible();
|
|
225
|
+
*/
|
|
226
|
+
protected keyedLocators<TKey extends string>(getLocator: (key: TKey) => PwLocator): Record<TKey, PwLocator> {
|
|
227
|
+
const handler: ProxyHandler<object> = {
|
|
228
|
+
get: (_t, prop) => {
|
|
229
|
+
// Avoid confusing Promise-like detection and ignore symbols.
|
|
230
|
+
if (prop === "then" || typeof prop === "symbol") {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return getLocator(String(prop) as TKey);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return new Proxy({}, handler) as Record<TKey, PwLocator>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public async getObjectId(options?: { timeoutMs?: number }): Promise<ObjectId> {
|
|
241
|
+
const timeoutMs = options?.timeoutMs ?? 10_000;
|
|
242
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
|
+
|
|
244
|
+
while (true) {
|
|
245
|
+
const url = this.page.url();
|
|
246
|
+
const match = url.match(/\/(\d+)(?:[/?#]|$)/);
|
|
247
|
+
if (match) {
|
|
248
|
+
return new ObjectId(match[1]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (Date.now() >= deadline) {
|
|
252
|
+
throw new Error(`getObjectId: could not find a numeric id in url '${url}' within ${timeoutMs}ms`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public async getObjectIdAsInt(options?: { timeoutMs?: number }): Promise<number> {
|
|
260
|
+
const objectId = await this.getObjectId(options);
|
|
261
|
+
return objectId.asInt();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wraps an async factory for a page object into a chainable Fluent<T>.
|
|
266
|
+
*
|
|
267
|
+
* The returned proxy:
|
|
268
|
+
* - forwards method calls to the resolved object
|
|
269
|
+
* - queues async method calls (Promise-returning) so they execute in order
|
|
270
|
+
* - is PromiseLike, so `await` yields the underlying object
|
|
271
|
+
*/
|
|
272
|
+
protected fluent<T extends object>(factory: () => Promise<T>): Fluent<T> {
|
|
273
|
+
// Cache the factory result so we don't repeat navigation/actions.
|
|
274
|
+
const rootPromise = factory();
|
|
275
|
+
const getRoot = () => rootPromise;
|
|
276
|
+
|
|
277
|
+
// Queue of side-effects (navigation + actions). Awaiting the fluent proxy awaits this queue.
|
|
278
|
+
let queue: Promise<void> = Promise.resolve();
|
|
279
|
+
|
|
280
|
+
let rootProxy: Fluent<T>;
|
|
281
|
+
|
|
282
|
+
const VALUE_RETURNING_METHODS = new Set<PropertyKey>([
|
|
283
|
+
"getObjectId",
|
|
284
|
+
"getObjectIdAsInt",
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const getCtorName = (obj: object): string => {
|
|
288
|
+
const o = obj as { constructor?: { name?: string } };
|
|
289
|
+
return o.constructor?.name ?? "object";
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const createValueProxy = <V>(getValue: () => Promise<V>): V & PromiseLike<V> => {
|
|
293
|
+
const handler: ProxyHandler<() => void> = {
|
|
294
|
+
get: (_t, prop) => {
|
|
295
|
+
if (prop === "then") {
|
|
296
|
+
return (onFulfilled?: ((value: V) => object) | null, onRejected?: ((reason: object) => object) | null) => {
|
|
297
|
+
return queue.then(() => getValue()).then(onFulfilled as never, onRejected as never);
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return createValueMemberProxy(getValue, prop);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const target = () => undefined;
|
|
306
|
+
return new Proxy(target, handler) as never as V & PromiseLike<V>;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
function createValueMemberProxy<P>(getParent: () => Promise<P>, member: PropertyKey): P & PromiseLike<P> {
|
|
310
|
+
const handler: ProxyHandler<() => void> = {
|
|
311
|
+
get: (_t, prop) => {
|
|
312
|
+
if (prop === "then") {
|
|
313
|
+
return (onFulfilled?: ((value: object) => object) | null, onRejected?: ((reason: object) => object) | null) => {
|
|
314
|
+
return queue
|
|
315
|
+
.then(async () => {
|
|
316
|
+
const parent = await getParent();
|
|
317
|
+
const value = Reflect.get(parent as never as object, member);
|
|
318
|
+
if (value == null) {
|
|
319
|
+
throw new Error(`Fluent: '${String(member)}' does not exist on ${getCtorName(parent as never as object)}`);
|
|
320
|
+
}
|
|
321
|
+
return value as object;
|
|
322
|
+
})
|
|
323
|
+
.then(onFulfilled as never, onRejected as never);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return createValueMemberProxy(async () => {
|
|
328
|
+
const parent = await getParent();
|
|
329
|
+
const value = Reflect.get(parent as never as object, member);
|
|
330
|
+
if (value == null) {
|
|
331
|
+
throw new Error(`Fluent: '${String(member)}' does not exist on ${getCtorName(parent as never as object)}`);
|
|
332
|
+
}
|
|
333
|
+
return value as P;
|
|
334
|
+
}, prop);
|
|
335
|
+
},
|
|
336
|
+
apply: (_t, _thisArg, args) => {
|
|
337
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
338
|
+
queue = queue
|
|
339
|
+
.then(async () => {
|
|
340
|
+
const parent = await getParent();
|
|
341
|
+
const value = Reflect.get(parent as never as object, member);
|
|
342
|
+
if (typeof value !== "function") {
|
|
343
|
+
throw new TypeError(`Fluent: '${String(member)}' is not a function on ${getCtorName(parent as never as object)}`);
|
|
344
|
+
}
|
|
345
|
+
const fn = value as (...a: object[]) => PromiseLike<object> | object;
|
|
346
|
+
const result = fn.apply(parent, args as object[]);
|
|
347
|
+
const resolved = result instanceof Promise ? await result : result;
|
|
348
|
+
resolve(resolved);
|
|
349
|
+
})
|
|
350
|
+
.catch(reject);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return createValueProxy(() => resultPromise as Promise<P>);
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const target = () => undefined;
|
|
358
|
+
return new Proxy(target, handler) as never as P & PromiseLike<P>;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const createMemberProxy = <P extends object>(getParent: () => Promise<P>, member: PropertyKey): Fluent<T> => {
|
|
362
|
+
const handler: ProxyHandler<() => void> = {
|
|
363
|
+
get: (_t, prop) => {
|
|
364
|
+
if (prop === "then") {
|
|
365
|
+
return (onFulfilled?: ((value: object) => object) | null, onRejected?: ((reason: object) => object) | null) => {
|
|
366
|
+
return queue
|
|
367
|
+
.then(async () => {
|
|
368
|
+
const parent = await getParent();
|
|
369
|
+
const value = Reflect.get(parent, member);
|
|
370
|
+
if (value == null) {
|
|
371
|
+
throw new Error(`Fluent: '${String(member)}' does not exist on ${parent.constructor?.name ?? "object"}`);
|
|
372
|
+
}
|
|
373
|
+
return value as object;
|
|
374
|
+
})
|
|
375
|
+
.then(onFulfilled as never, onRejected as never);
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Chain deeper: resolve this member value, then access its property.
|
|
380
|
+
return createMemberProxy(async () => {
|
|
381
|
+
const parent = await getParent();
|
|
382
|
+
const value = Reflect.get(parent, member);
|
|
383
|
+
if (value == null) {
|
|
384
|
+
throw new Error(`Fluent: '${String(member)}' does not exist on ${parent.constructor?.name ?? "object"}`);
|
|
385
|
+
}
|
|
386
|
+
return value as P;
|
|
387
|
+
}, prop);
|
|
388
|
+
},
|
|
389
|
+
apply: (_t, _thisArg, args) => {
|
|
390
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
391
|
+
// Call parent[member](...args) with correct `this` binding.
|
|
392
|
+
queue = queue
|
|
393
|
+
.then(async () => {
|
|
394
|
+
const parent = await getParent();
|
|
395
|
+
const value = Reflect.get(parent, member);
|
|
396
|
+
if (typeof value !== "function") {
|
|
397
|
+
throw new TypeError(`Fluent: '${String(member)}' is not a function on ${parent.constructor?.name ?? "object"}`);
|
|
398
|
+
}
|
|
399
|
+
const fn = value as (...a: object[]) => PromiseLike<object> | object;
|
|
400
|
+
// Preserve `this` so methods can access instance fields (e.g. composed child POMs).
|
|
401
|
+
const result = fn.apply(parent, args as object[]);
|
|
402
|
+
const resolved = result instanceof Promise ? await result : result;
|
|
403
|
+
resolve(resolved);
|
|
404
|
+
})
|
|
405
|
+
.catch(reject);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (VALUE_RETURNING_METHODS.has(member)) {
|
|
409
|
+
return createValueProxy(() => resultPromise as Promise<object>);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// After calling a method, stay on the *root object* so you can chain sibling methods.
|
|
413
|
+
return rootProxy;
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const target = () => undefined;
|
|
418
|
+
return new Proxy(target, handler) as never as Fluent<T>;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const rootHandler: ProxyHandler<object> = {
|
|
422
|
+
get: (_t, prop) => {
|
|
423
|
+
if (prop === "then") {
|
|
424
|
+
return (onFulfilled?: ((value: T) => object) | null, onRejected?: ((reason: object) => object) | null) => {
|
|
425
|
+
return queue.then(() => getRoot()).then(onFulfilled as never, onRejected as never);
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return createMemberProxy(getRoot, prop);
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const rootTarget = {};
|
|
433
|
+
rootProxy = new Proxy(rootTarget, rootHandler) as Fluent<T>;
|
|
434
|
+
return rootProxy;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Clicks on an element with the specified data-testid
|
|
439
|
+
* @param testId The data-testid of the element to click
|
|
440
|
+
*/
|
|
441
|
+
public async clickByTestId(testId: string, annotationText: string = "", wait: boolean = true): Promise<void> {
|
|
442
|
+
await this.pointer.animateCursorToElement(this.selectorForTestId(testId), true, 200, annotationText, {
|
|
443
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
444
|
+
if (!wait) return;
|
|
445
|
+
if (!clickedTestId || !instrumented) return;
|
|
446
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
public async clickLocator(locator: PwLocator, annotationText: string = "", wait: boolean = true): Promise<void> {
|
|
452
|
+
await this.pointer.animateCursorToElement(locator, true, 200, annotationText, {
|
|
453
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
454
|
+
if (!wait) return;
|
|
455
|
+
if (!clickedTestId || !instrumented) return;
|
|
456
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
protected async fillInputByTestId(testId: string, text: string, annotationText: string = ""): Promise<void> {
|
|
462
|
+
await this.pointer.animateCursorToElementAndClickAndFill(this.selectorForTestId(testId), text, true, 200, annotationText, {
|
|
463
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
464
|
+
if (!clickedTestId || !instrumented) return;
|
|
465
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Interacts with a vue-select control rooted by a data-testid.
|
|
472
|
+
* This is emitted frequently by the generator; keeping it here reduces per-page duplicated code.
|
|
473
|
+
*/
|
|
474
|
+
protected async selectVSelectByTestId(testId: string, value: string, timeOut: number = 500, annotationText: string = ""): Promise<void> {
|
|
475
|
+
const root = this.locatorByTestId(testId);
|
|
476
|
+
const input = root.locator("input");
|
|
477
|
+
|
|
478
|
+
await this.pointer.animateCursorToElement(input, false, 200, annotationText);
|
|
479
|
+
await input.click({ force: true });
|
|
480
|
+
await this.pointer.animateCursorToElementAndClickAndFill(input, value, false, 200, annotationText);
|
|
481
|
+
await this.page.waitForTimeout(timeOut);
|
|
482
|
+
|
|
483
|
+
const option = root.locator("ul.vs__dropdown-menu li[role='option']").first();
|
|
484
|
+
if (await option.count()) {
|
|
485
|
+
await this.pointer.animateCursorToElement(option, true, 200, annotationText, {
|
|
486
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
487
|
+
if (!clickedTestId || !instrumented) return;
|
|
488
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
public async fillInputByLocator(locator: PwLocator, text: string, annotationText: string = ""): Promise<void> {
|
|
495
|
+
await this.pointer.animateCursorToElementAndClickAndFill(locator, text, true, 200, annotationText, {
|
|
496
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
497
|
+
if (!clickedTestId || !instrumented) return;
|
|
498
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
protected async clickByAriaLabel(ariaLabel: string, annotationText: string = ""): Promise<void> {
|
|
504
|
+
await this.pointer.animateCursorToElement(`[aria-label="${ariaLabel}"]`, true, 200, annotationText, {
|
|
505
|
+
afterClick: async ({ testId: clickedTestId, instrumented }: AfterPointerClickInfo) => {
|
|
506
|
+
if (!clickedTestId || !instrumented) return;
|
|
507
|
+
await this.waitForTestIdClickEventAfter(clickedTestId);
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Types text into an element with the specified data-testid
|
|
514
|
+
* @param testId The data-testid of the element to type into
|
|
515
|
+
* @param text The text to type
|
|
516
|
+
*/
|
|
517
|
+
protected async typeByTestId(testId: string, text: string): Promise<void> {
|
|
518
|
+
await this.fillInputByTestId(testId, text);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Checks if an element with the specified data-testid is visible
|
|
523
|
+
* @param testId The data-testid of the element to check
|
|
524
|
+
* @returns True if the element is visible, false otherwise
|
|
525
|
+
*/
|
|
526
|
+
protected async isVisibleByTestId(testId: string): Promise<boolean> {
|
|
527
|
+
return await this.page.isVisible(this.selectorForTestId(testId));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Gets the text content of an element with the specified data-testid
|
|
532
|
+
* @param testId The data-testid of the element to get text from
|
|
533
|
+
* @returns The text content of the element
|
|
534
|
+
*/
|
|
535
|
+
protected async getTextByTestId(testId: string): Promise<string | null> {
|
|
536
|
+
return await this.page.textContent(this.selectorForTestId(testId));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Waits for an element with the specified data-testid to be visible
|
|
541
|
+
* @param testId The data-testid of the element to wait for
|
|
542
|
+
* @param options Optional timeout and other options
|
|
543
|
+
* @param options.timeout The maximum time to wait for the element to be visible (default is 3000ms)
|
|
544
|
+
* @returns A promise that resolves when the element is visible
|
|
545
|
+
*/
|
|
546
|
+
protected async waitForTestId(testId: string, options?: { timeout?: number }): Promise<void> {
|
|
547
|
+
await this.page.waitForSelector(this.selectorForTestId(testId), options);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Hovers over an element with the specified data-testid
|
|
552
|
+
* @param testId The data-testid of the element to hover over
|
|
553
|
+
*/
|
|
554
|
+
protected async hoverByTestId(testId: string): Promise<void> {
|
|
555
|
+
const selector = this.selectorForTestId(testId);
|
|
556
|
+
await this.pointer.animateCursorToElement(selector, false, 200, "");
|
|
557
|
+
await this.page.hover(selector);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Selects an option from a dropdown with the specified data-testid
|
|
562
|
+
* @param testId The data-testid of the dropdown
|
|
563
|
+
* @param value The value to select
|
|
564
|
+
*/
|
|
565
|
+
protected async selectByTestId(testId: string, value: string): Promise<void> {
|
|
566
|
+
await this.page.selectOption(this.selectorForTestId(testId), value);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Locator as PwLocator, Page as PwPage } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
export interface PlaywrightAnimationOptions {
|
|
4
|
+
/**
|
|
5
|
+
* When false, cursor animations are disabled (but clicks/fills still happen).
|
|
6
|
+
*
|
|
7
|
+
* Default: true
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extra delay in ms before performing the action.
|
|
13
|
+
*
|
|
14
|
+
* Default: 0
|
|
15
|
+
*/
|
|
16
|
+
extraDelayMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let animationOptions: PlaywrightAnimationOptions = { enabled: true, extraDelayMs: 0 };
|
|
20
|
+
|
|
21
|
+
export function setPlaywrightAnimationOptions(options: PlaywrightAnimationOptions): void {
|
|
22
|
+
animationOptions = {
|
|
23
|
+
enabled: options?.enabled ?? true,
|
|
24
|
+
extraDelayMs: options?.extraDelayMs ?? 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AfterPointerClickInfo {
|
|
29
|
+
/**
|
|
30
|
+
* Resolved test id from the clicked element (if present).
|
|
31
|
+
*/
|
|
32
|
+
testId?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the click should be considered “instrumented”.
|
|
36
|
+
*
|
|
37
|
+
* BasePage uses this flag to decide whether to wait for the injected click event.
|
|
38
|
+
*/
|
|
39
|
+
instrumented: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AfterPointerClick = (info: AfterPointerClickInfo) => void | Promise<void>;
|
|
43
|
+
|
|
44
|
+
type ElementTarget = string | PwLocator;
|
|
45
|
+
|
|
46
|
+
export class Pointer {
|
|
47
|
+
private readonly page: PwPage;
|
|
48
|
+
private readonly testIdAttribute: string;
|
|
49
|
+
|
|
50
|
+
public constructor(page: PwPage, testIdAttribute: string) {
|
|
51
|
+
this.page = page;
|
|
52
|
+
this.testIdAttribute = (testIdAttribute ?? "data-testid").trim() || "data-testid";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private toLocator(target: ElementTarget): PwLocator {
|
|
56
|
+
return typeof target === "string" ? this.page.locator(target) : target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async getTestId(locator: PwLocator): Promise<string | undefined> {
|
|
60
|
+
const raw = await locator.first().getAttribute(this.testIdAttribute);
|
|
61
|
+
const trimmed = (raw ?? "").trim();
|
|
62
|
+
return trimmed || undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public async animateCursorToElement(
|
|
66
|
+
target: ElementTarget,
|
|
67
|
+
executeClick: boolean = true,
|
|
68
|
+
delayMs: number = 100,
|
|
69
|
+
_annotationText: string = "",
|
|
70
|
+
options?: {
|
|
71
|
+
afterClick?: AfterPointerClick;
|
|
72
|
+
},
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const locator = this.toLocator(target);
|
|
75
|
+
|
|
76
|
+
// Best-effort “animation”: make sure the element is scrolled into view and add a delay.
|
|
77
|
+
try {
|
|
78
|
+
await locator.first().scrollIntoViewIfNeeded();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// If the element detaches during navigation, let the subsequent click/fill surface the error.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const totalDelay = Math.max(0, delayMs) + Math.max(0, animationOptions.extraDelayMs ?? 0);
|
|
85
|
+
if (animationOptions.enabled !== false && totalDelay > 0) {
|
|
86
|
+
await this.page.waitForTimeout(totalDelay);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let clickedTestId: string | undefined;
|
|
90
|
+
if (executeClick) {
|
|
91
|
+
try {
|
|
92
|
+
clickedTestId = await this.getTestId(locator);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
clickedTestId = undefined;
|
|
96
|
+
}
|
|
97
|
+
await locator.first().click({ force: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (options?.afterClick) {
|
|
101
|
+
await options.afterClick({
|
|
102
|
+
testId: clickedTestId,
|
|
103
|
+
instrumented: Boolean(clickedTestId),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async animateCursorToElementAndClickAndFill(
|
|
109
|
+
target: ElementTarget,
|
|
110
|
+
text: string,
|
|
111
|
+
executeClick: boolean = true,
|
|
112
|
+
delayMs: number = 100,
|
|
113
|
+
annotationText: string = "",
|
|
114
|
+
options?: {
|
|
115
|
+
afterClick?: AfterPointerClick;
|
|
116
|
+
},
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
// Reuse the click flow so the afterClick callback observes the click.
|
|
119
|
+
await this.animateCursorToElement(target, executeClick, delayMs, annotationText, options);
|
|
120
|
+
|
|
121
|
+
const locator = this.toLocator(target);
|
|
122
|
+
await locator.first().fill(text);
|
|
123
|
+
}
|
|
124
|
+
}
|