@sungen/driver-ui 3.1.2-beta.97
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/package.json +25 -0
- package/src/index.ts +119 -0
- package/src/patterns/assertion-patterns.ts +691 -0
- package/src/patterns/capture-patterns.ts +97 -0
- package/src/patterns/form-patterns.ts +167 -0
- package/src/patterns/interaction-patterns.ts +465 -0
- package/src/patterns/keyboard-patterns.ts +51 -0
- package/src/patterns/navigation-patterns.ts +140 -0
- package/src/patterns/scope-patterns.ts +40 -0
- package/src/patterns/scroll-patterns.ts +27 -0
- package/src/patterns/setup-patterns.ts +76 -0
- package/src/patterns/table-patterns.ts +279 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { ParsedStep } from '@sun-asterisk/sungen';
|
|
2
|
+
import { StepPattern } from '@sun-asterisk/sungen';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract Playwright waitFor state from step text.
|
|
6
|
+
* Defaults to 'visible' if no known state keyword is found.
|
|
7
|
+
*/
|
|
8
|
+
function extractWaitState(text: string): string {
|
|
9
|
+
if (/\bhidden\b/.test(text)) return 'hidden';
|
|
10
|
+
if (/\bdetached\b/.test(text)) return 'detached';
|
|
11
|
+
if (/\bvisible\b/.test(text)) return 'visible';
|
|
12
|
+
return 'visible';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map a plain element keyword (no brackets) to a Playwright ARIA role.
|
|
17
|
+
*/
|
|
18
|
+
function elementKeywordToRole(text: string): string | null {
|
|
19
|
+
const match = text.match(/\bwait(?:s)?\s+for\s+(dialog|modal|alert|alertdialog|tooltip|menu|listbox|combobox|grid|table|status|banner|navigation|main|region)\b/i);
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
const keyword = match[1].toLowerCase();
|
|
22
|
+
const roleMap: Record<string, string> = {
|
|
23
|
+
modal: 'dialog',
|
|
24
|
+
alert: 'alertdialog',
|
|
25
|
+
};
|
|
26
|
+
return roleMap[keyword] || keyword;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Interaction patterns: click, hover, press, wait, alert
|
|
31
|
+
*/
|
|
32
|
+
export const interactionPatterns: StepPattern[] = [
|
|
33
|
+
// === Browser Alert (system dialog) patterns ===
|
|
34
|
+
// These must appear BEFORE the click that triggers the alert in Gherkin:
|
|
35
|
+
// When User click [OK] alert ← registers page.once('dialog', ...)
|
|
36
|
+
// And User click [Delete] button ← triggers the alert
|
|
37
|
+
{
|
|
38
|
+
name: 'alert-accept',
|
|
39
|
+
matcher: (step: ParsedStep) =>
|
|
40
|
+
step.elementType === 'alert' &&
|
|
41
|
+
(step.text.includes('click') || step.text.includes('clicks')) &&
|
|
42
|
+
!step.dataRef &&
|
|
43
|
+
!!(step.selectorRef && /^(ok|accept|yes|confirm)$/i.test(step.selectorRef)),
|
|
44
|
+
resolver: (step, context) => {
|
|
45
|
+
return {
|
|
46
|
+
templateName: 'alert-accept-action',
|
|
47
|
+
data: {},
|
|
48
|
+
comment: `Accept browser alert`,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
priority: 20,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'alert-dismiss',
|
|
55
|
+
matcher: (step: ParsedStep) =>
|
|
56
|
+
step.elementType === 'alert' &&
|
|
57
|
+
(step.text.includes('click') || step.text.includes('clicks')) &&
|
|
58
|
+
!step.dataRef &&
|
|
59
|
+
!!(step.selectorRef && /^(cancel|dismiss|no)$/i.test(step.selectorRef)),
|
|
60
|
+
resolver: (step, context) => {
|
|
61
|
+
return {
|
|
62
|
+
templateName: 'alert-dismiss-action',
|
|
63
|
+
data: {},
|
|
64
|
+
comment: `Dismiss browser alert`,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
priority: 20,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'alert-fill',
|
|
71
|
+
matcher: (step: ParsedStep) =>
|
|
72
|
+
step.elementType === 'alert' &&
|
|
73
|
+
step.text.includes('fill') &&
|
|
74
|
+
!!step.dataRef,
|
|
75
|
+
resolver: (step, context) => {
|
|
76
|
+
let fillValue: string;
|
|
77
|
+
try {
|
|
78
|
+
fillValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
79
|
+
} catch {
|
|
80
|
+
fillValue = `\${${step.dataRef}}`;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
templateName: 'alert-fill-action',
|
|
84
|
+
data: { fillValue },
|
|
85
|
+
comment: `Fill browser prompt with ${step.dataRef}`,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
priority: 20,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'unknown-element-action',
|
|
92
|
+
matcher: (step: ParsedStep) => {
|
|
93
|
+
const isTargetElement = step.selectorRef && /^target\s/i.test(step.selectorRef);
|
|
94
|
+
const isInteractionAction = !step.text.includes('see') && !step.text.includes('sees');
|
|
95
|
+
return isTargetElement && !!step.dataRef && step.text.includes('with') && isInteractionAction;
|
|
96
|
+
},
|
|
97
|
+
resolver: (step, context) => {
|
|
98
|
+
let selectorValue = '';
|
|
99
|
+
let nth: number | undefined;
|
|
100
|
+
try {
|
|
101
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
102
|
+
step.selectorRef!, context.featureName, step.elementType, step.nth
|
|
103
|
+
);
|
|
104
|
+
selectorValue = resolved.value || '';
|
|
105
|
+
nth = resolved.nth;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
selectorValue = '';
|
|
108
|
+
nth = undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let dataValue: string;
|
|
112
|
+
try {
|
|
113
|
+
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
dataValue = `\${${step.dataRef}}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let actionMethod = 'click';
|
|
119
|
+
if (step.text.includes('hover')) actionMethod = 'hover';
|
|
120
|
+
else if (step.text.includes('double')) actionMethod = 'dblclick';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
templateName: 'unknown-element-action',
|
|
124
|
+
data: { selectorValue, dataValue, action: actionMethod, nth },
|
|
125
|
+
comment: `${actionMethod.charAt(0).toUpperCase() + actionMethod.slice(1)} ${step.selectorRef} with ${step.dataRef}`,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
priority: 14, // Below click-element-with-text (15) so known selectors use proper locator
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'click-element-with-text',
|
|
132
|
+
matcher: (step: ParsedStep) =>
|
|
133
|
+
(step.text.includes('clicks') || step.text.includes('click'))
|
|
134
|
+
&& !!step.selectorRef
|
|
135
|
+
&& !!(step.dataRef || step.value)
|
|
136
|
+
&& step.text.includes('with'),
|
|
137
|
+
resolver: (step, context) => {
|
|
138
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
139
|
+
step.selectorRef!, undefined, step.elementType, step.nth
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
let dataValue: string;
|
|
143
|
+
if (step.value) {
|
|
144
|
+
dataValue = step.value;
|
|
145
|
+
} else {
|
|
146
|
+
try {
|
|
147
|
+
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
148
|
+
} catch {
|
|
149
|
+
dataValue = `\${${step.dataRef}}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { name: _name, ...resolvedWithoutName } = resolved;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
templateName: 'click-element-with-text',
|
|
157
|
+
data: { ...resolvedWithoutName, dataValue },
|
|
158
|
+
comment: `Click ${step.selectorRef} with text ${step.dataRef || step.value}`,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
priority: 15,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'click-element',
|
|
165
|
+
matcher: (step: ParsedStep) =>
|
|
166
|
+
(step.text.includes('clicks') || step.text.includes('click')) && !!step.selectorRef,
|
|
167
|
+
resolver: (step, context) => {
|
|
168
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
169
|
+
return {
|
|
170
|
+
templateName: 'click-action',
|
|
171
|
+
data: { ...resolved, selectorRef: step.selectorRef },
|
|
172
|
+
comment: `Click ${step.selectorRef}`,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
priority: 10,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'double-click',
|
|
179
|
+
matcher: (step: ParsedStep) =>
|
|
180
|
+
(step.text.includes('double click') || step.text.includes('double-click') ||
|
|
181
|
+
(step.text.includes('double') && step.text.includes('clicks'))) && !!step.selectorRef,
|
|
182
|
+
resolver: (step, context) => {
|
|
183
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
184
|
+
return {
|
|
185
|
+
templateName: 'double-click-action',
|
|
186
|
+
data: { ...resolved, selectorRef: step.selectorRef },
|
|
187
|
+
comment: `Double click ${step.selectorRef}`,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
priority: 11,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'hover-element',
|
|
194
|
+
matcher: (step: ParsedStep) =>
|
|
195
|
+
(step.text.includes('hovers') || step.text.match(/\bhover\b/)) && !!step.selectorRef,
|
|
196
|
+
resolver: (step, context) => {
|
|
197
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
198
|
+
return {
|
|
199
|
+
templateName: 'hover-action',
|
|
200
|
+
data: { ...resolved, selectorRef: step.selectorRef },
|
|
201
|
+
comment: `Hover ${step.selectorRef}`,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
priority: 8,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'click-by-data-text',
|
|
208
|
+
matcher: (step: ParsedStep) =>
|
|
209
|
+
(step.text.includes('clicks') || step.text.includes('click')) && !step.selectorRef && !!step.dataRef,
|
|
210
|
+
resolver: (step, context) => {
|
|
211
|
+
let value: string;
|
|
212
|
+
try {
|
|
213
|
+
value = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
value = `\${${step.dataRef}}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
templateName: 'click-action',
|
|
220
|
+
data: { strategy: 'text', value },
|
|
221
|
+
comment: `Click ${step.dataRef}`,
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
priority: 5,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'wait-for-time',
|
|
228
|
+
matcher: (step: ParsedStep) =>
|
|
229
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) && /\d+/.test(step.text),
|
|
230
|
+
resolver: (step, context) => {
|
|
231
|
+
const match = step.text.match(/(\d+)\s*(seconds?|ms|milliseconds?)/);
|
|
232
|
+
let duration = 1000;
|
|
233
|
+
|
|
234
|
+
if (match) {
|
|
235
|
+
const num = parseInt(match[1]);
|
|
236
|
+
const unit = match[2].toLowerCase();
|
|
237
|
+
duration = unit.startsWith('s') ? num * 1000 : num;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
templateName: 'wait-timeout',
|
|
242
|
+
data: { duration },
|
|
243
|
+
comment: `Wait for ${duration}ms`,
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
priority: 7,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'wait-for-role-with-text',
|
|
250
|
+
matcher: (step: ParsedStep) =>
|
|
251
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) &&
|
|
252
|
+
!step.selectorRef &&
|
|
253
|
+
!!step.dataRef &&
|
|
254
|
+
!!elementKeywordToRole(step.text),
|
|
255
|
+
resolver: (step, context) => {
|
|
256
|
+
const role = elementKeywordToRole(step.text)!;
|
|
257
|
+
|
|
258
|
+
let dataValue: string;
|
|
259
|
+
try {
|
|
260
|
+
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
dataValue = `\${${step.dataRef}}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const state = extractWaitState(step.text);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
templateName: 'wait-for-role-with-data',
|
|
269
|
+
data: { role, dataValue, state },
|
|
270
|
+
comment: `Wait for ${role} with ${step.dataRef} to be ${state}`,
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
priority: 11,
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'wait-for-role',
|
|
277
|
+
matcher: (step: ParsedStep) =>
|
|
278
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) &&
|
|
279
|
+
!step.selectorRef &&
|
|
280
|
+
!step.dataRef &&
|
|
281
|
+
!!elementKeywordToRole(step.text),
|
|
282
|
+
resolver: (step, context) => {
|
|
283
|
+
const role = elementKeywordToRole(step.text)!;
|
|
284
|
+
const state = extractWaitState(step.text);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
templateName: 'wait-for-role',
|
|
288
|
+
data: { role, state },
|
|
289
|
+
comment: `Wait for ${role} to be ${state}`,
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
priority: 11,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: 'wait-for-page',
|
|
296
|
+
matcher: (step: ParsedStep) =>
|
|
297
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) && step.elementType === 'page',
|
|
298
|
+
resolver: (step, context) => {
|
|
299
|
+
let path = step.featurePath || '/';
|
|
300
|
+
|
|
301
|
+
if (step.selectorRef) {
|
|
302
|
+
try {
|
|
303
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
304
|
+
step.selectorRef, context.featureName, step.elementType, step.nth
|
|
305
|
+
);
|
|
306
|
+
path = resolved.value || path;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// fallback to featurePath
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const isAbsoluteUrl = /^https?:\/\//.test(path);
|
|
313
|
+
let pathRegex: string;
|
|
314
|
+
if (isAbsoluteUrl) {
|
|
315
|
+
const url = new URL(path);
|
|
316
|
+
const hostEscaped = url.hostname.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
317
|
+
const pathEscaped = url.pathname !== '/' ? url.pathname.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') : '';
|
|
318
|
+
pathRegex = hostEscaped + pathEscaped;
|
|
319
|
+
} else {
|
|
320
|
+
pathRegex = path.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
templateName: 'wait-for-page',
|
|
325
|
+
data: { pathRegex },
|
|
326
|
+
comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
priority: 9,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'wait-for-element-with-text',
|
|
333
|
+
matcher: (step: ParsedStep) =>
|
|
334
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) &&
|
|
335
|
+
!!step.selectorRef &&
|
|
336
|
+
!!step.dataRef,
|
|
337
|
+
resolver: (step, context) => {
|
|
338
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
339
|
+
|
|
340
|
+
let dataValue: string;
|
|
341
|
+
try {
|
|
342
|
+
dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
dataValue = `\${${step.dataRef}}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const state = extractWaitState(step.text);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
templateName: 'wait-for-element-with-text',
|
|
351
|
+
data: { ...resolved, selectorRef: step.selectorRef, dataValue, state },
|
|
352
|
+
comment: `Wait for ${step.selectorRef} with ${step.dataRef} to be ${state}`,
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
priority: 10,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: 'wait-for-element',
|
|
359
|
+
matcher: (step: ParsedStep) =>
|
|
360
|
+
(step.text.includes('wait for') || step.text.includes('waits for')) && !!step.selectorRef,
|
|
361
|
+
resolver: (step, context) => {
|
|
362
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
363
|
+
const state = extractWaitState(step.text);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
templateName: 'wait-for-element',
|
|
367
|
+
data: { ...resolved, selectorRef: step.selectorRef, state },
|
|
368
|
+
comment: `Wait for ${step.selectorRef} to be ${state}`,
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
priority: 8,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'toggle-switch',
|
|
375
|
+
matcher: (step: ParsedStep) =>
|
|
376
|
+
(step.text.includes('toggles') || step.text.match(/\btoggle\b/)) && !!step.selectorRef,
|
|
377
|
+
resolver: (step, context) => {
|
|
378
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
379
|
+
return {
|
|
380
|
+
templateName: 'toggle-action',
|
|
381
|
+
data: { ...resolved, selectorRef: step.selectorRef },
|
|
382
|
+
comment: `Toggle ${step.selectorRef}`,
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
priority: 9,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'drag-to',
|
|
389
|
+
matcher: (step: ParsedStep) =>
|
|
390
|
+
(step.text.includes('drags') || step.text.match(/\bdrag\b/)) &&
|
|
391
|
+
/\bto\s+\[/.test(step.text) && !!step.selectorRef,
|
|
392
|
+
resolver: (step, context) => {
|
|
393
|
+
// Extract both selector refs: [Source] ... to [Target]
|
|
394
|
+
const allRefs = step.text.match(/\[([^\]]+)\]/g);
|
|
395
|
+
if (!allRefs || allRefs.length < 2) {
|
|
396
|
+
return {
|
|
397
|
+
templateName: 'drag-action',
|
|
398
|
+
data: { strategy: 'text', value: step.selectorRef, targetLocator: 'page.locator(\'TODO\')' },
|
|
399
|
+
comment: `Drag ${step.selectorRef} (missing target)`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const sourceRef = allRefs[0].slice(1, -1);
|
|
403
|
+
const targetRef = allRefs[1].slice(1, -1);
|
|
404
|
+
|
|
405
|
+
const sourceResolved = context.selectorResolver.resolveSelector(sourceRef, undefined, step.elementType, step.nth);
|
|
406
|
+
const targetResolved = context.selectorResolver.resolveSelector(targetRef, undefined, undefined, 0);
|
|
407
|
+
|
|
408
|
+
// Build target locator expression from resolved selector
|
|
409
|
+
let targetLocator: string;
|
|
410
|
+
switch (targetResolved.strategy) {
|
|
411
|
+
case 'role':
|
|
412
|
+
targetLocator = targetResolved.name
|
|
413
|
+
? `page.getByRole('${targetResolved.role}', { name: '${targetResolved.name}' })`
|
|
414
|
+
: `page.getByRole('${targetResolved.role}')`;
|
|
415
|
+
break;
|
|
416
|
+
case 'testid':
|
|
417
|
+
targetLocator = `page.getByTestId('${targetResolved.value}')`;
|
|
418
|
+
break;
|
|
419
|
+
case 'text':
|
|
420
|
+
targetLocator = `page.getByText('${targetResolved.value}')`;
|
|
421
|
+
break;
|
|
422
|
+
case 'locator':
|
|
423
|
+
targetLocator = `page.locator('${targetResolved.value}')`;
|
|
424
|
+
break;
|
|
425
|
+
default:
|
|
426
|
+
targetLocator = `page.getByText('${targetRef}')`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
templateName: 'drag-action',
|
|
431
|
+
data: { ...sourceResolved, targetLocator },
|
|
432
|
+
comment: `Drag ${sourceRef} to ${targetRef}`,
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
priority: 10,
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'expand-element',
|
|
439
|
+
matcher: (step: ParsedStep) =>
|
|
440
|
+
(step.text.includes('expands') || step.text.match(/\bexpand\b/)) && !!step.selectorRef,
|
|
441
|
+
resolver: (step, context) => {
|
|
442
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
443
|
+
return {
|
|
444
|
+
templateName: 'expand-action',
|
|
445
|
+
data: { ...resolved, selectorRef: step.selectorRef, direction: 'expand' },
|
|
446
|
+
comment: `Expand ${step.selectorRef}`,
|
|
447
|
+
};
|
|
448
|
+
},
|
|
449
|
+
priority: 9,
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: 'collapse-element',
|
|
453
|
+
matcher: (step: ParsedStep) =>
|
|
454
|
+
(step.text.includes('collapses') || step.text.match(/\bcollapse\b/)) && !!step.selectorRef,
|
|
455
|
+
resolver: (step, context) => {
|
|
456
|
+
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
457
|
+
return {
|
|
458
|
+
templateName: 'expand-action',
|
|
459
|
+
data: { ...resolved, selectorRef: step.selectorRef, direction: 'collapse' },
|
|
460
|
+
comment: `Collapse ${step.selectorRef}`,
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
priority: 9,
|
|
464
|
+
},
|
|
465
|
+
];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard Patterns
|
|
3
|
+
* Handles: press Key key, press Key on [Target] type
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StepPattern } from '@sun-asterisk/sungen';
|
|
7
|
+
|
|
8
|
+
export const keyboardPatterns: StepPattern[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'press-key-global',
|
|
11
|
+
matcher: (step) => {
|
|
12
|
+
const text = step.text.toLowerCase();
|
|
13
|
+
return /\bpress(?:es)?\s+\w+\s+key\b/.test(text) && !text.includes(' on ');
|
|
14
|
+
},
|
|
15
|
+
resolver: (step, context) => {
|
|
16
|
+
// Extract key name: "User press Escape key" → "Escape"
|
|
17
|
+
const match = step.text.match(/press(?:es)?\s+(\w+)\s+key/i);
|
|
18
|
+
const key = match ? match[1] : 'Enter';
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
templateName: 'keyboard-global-action',
|
|
22
|
+
data: { key },
|
|
23
|
+
comment: `Press ${key} key`,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
priority: 12,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'press-key-on-element',
|
|
30
|
+
matcher: (step) => {
|
|
31
|
+
const text = step.text.toLowerCase();
|
|
32
|
+
return /\bpress(?:es)?\s+\w+\s+on\b/.test(text) && !!step.selectorRef;
|
|
33
|
+
},
|
|
34
|
+
resolver: (step, context) => {
|
|
35
|
+
// Extract key: "User press Enter on [Search] field" → "Enter"
|
|
36
|
+
const match = step.text.match(/press(?:es)?\s+(\w+)\s+on/i);
|
|
37
|
+
const key = match ? match[1] : 'Enter';
|
|
38
|
+
|
|
39
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
40
|
+
step.selectorRef!, context.featureName, step.elementType, step.nth
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
templateName: 'press-action',
|
|
45
|
+
data: { ...resolved, key },
|
|
46
|
+
comment: `Press ${key} on ${step.selectorRef}`,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
priority: 12,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ParsedStep } from '@sun-asterisk/sungen';
|
|
2
|
+
import { StepPattern } from '@sun-asterisk/sungen';
|
|
3
|
+
import { inferPath, resolvePathVariables, getPathCode } from '@sun-asterisk/sungen';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Navigation patterns: goto, navigate, open page
|
|
7
|
+
*/
|
|
8
|
+
export const navigationPatterns: StepPattern[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'open-page-type',
|
|
11
|
+
matcher: (step: ParsedStep) =>
|
|
12
|
+
(step.text.includes('open') || step.text.includes('opens') || step.text.includes('is on') || step.text.includes('navigate to')) &&
|
|
13
|
+
step.elementType === 'page',
|
|
14
|
+
resolver: (step, context) => {
|
|
15
|
+
const isThen = context.effectiveKeyword === 'Then';
|
|
16
|
+
// Cross-screen page refs (e.g. Then User is on [dashboard] page) won't be in the
|
|
17
|
+
// current screen's selectors. Fall back to /<ref>/ for assertion paths instead of
|
|
18
|
+
// the current featurePath, which would silently make the assertion always pass.
|
|
19
|
+
const pathFallback = isThen && step.selectorRef
|
|
20
|
+
? `/${step.selectorRef}/`
|
|
21
|
+
: (context.featurePath || '/');
|
|
22
|
+
let path = pathFallback;
|
|
23
|
+
|
|
24
|
+
if (step.selectorRef) {
|
|
25
|
+
try {
|
|
26
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
27
|
+
step.selectorRef, context.featureName, step.elementType, step.nth
|
|
28
|
+
);
|
|
29
|
+
path = resolved.value || pathFallback;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
path = pathFallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const finalPath = resolvePathVariables(path, context.scenarioSteps || []);
|
|
36
|
+
const isAbsoluteUrl = /^https?:\/\//.test(finalPath);
|
|
37
|
+
|
|
38
|
+
// Then User is on [X] page — assert URL, don't navigate.
|
|
39
|
+
if (isThen) {
|
|
40
|
+
return {
|
|
41
|
+
templateName: 'route-assertion',
|
|
42
|
+
data: { path: finalPath },
|
|
43
|
+
comment: step.selectorRef ? `Assert on ${step.selectorRef} page` : `Assert page route`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
templateName: 'navigation',
|
|
49
|
+
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath },
|
|
50
|
+
comment: step.selectorRef ? `Open ${step.selectorRef} page` : `Navigate to page`,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
priority: 16,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'open-page',
|
|
57
|
+
matcher: (step: ParsedStep) =>
|
|
58
|
+
(step.text.includes('open') || step.text.includes('opens')) &&
|
|
59
|
+
step.text.includes('page') &&
|
|
60
|
+
step.elementType !== 'page',
|
|
61
|
+
resolver: (step, context) => {
|
|
62
|
+
const pageMatch = step.text.match(/open[s]?\s+\[([^\]]+)\]/i) ||
|
|
63
|
+
step.text.match(/open[s]?\s+"([^"]+)"/i);
|
|
64
|
+
const pageName = pageMatch ? pageMatch[1] : 'page';
|
|
65
|
+
|
|
66
|
+
const inferredPath = inferPath(context.featurePath, {
|
|
67
|
+
featureName: context.featureName,
|
|
68
|
+
screenName: context.screenName,
|
|
69
|
+
});
|
|
70
|
+
const resolvedPath = resolvePathVariables(inferredPath, context.scenarioSteps || []);
|
|
71
|
+
const pathCode = getPathCode(resolvedPath);
|
|
72
|
+
const cleanPath = pathCode.replace(/^['`]|['`]$/g, '');
|
|
73
|
+
const isAbsoluteUrl = /^https?:\/\//.test(cleanPath);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
templateName: 'navigation',
|
|
77
|
+
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: cleanPath },
|
|
78
|
+
comment: `Open ${pageName}`,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
priority: 15,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'navigate-to-route',
|
|
85
|
+
matcher: (step: ParsedStep) =>
|
|
86
|
+
(step.text.includes('navigate to') || step.text.includes('navigates to') || step.text.includes('is on')) &&
|
|
87
|
+
!!(step.selectorRef || step.dataRef),
|
|
88
|
+
resolver: (step, context) => {
|
|
89
|
+
const route = step.selectorRef || step.dataRef;
|
|
90
|
+
const isThen = context.effectiveKeyword === 'Then';
|
|
91
|
+
|
|
92
|
+
const inferredPath = inferPath(context.featurePath, {
|
|
93
|
+
featureName: context.featureName,
|
|
94
|
+
screenName: context.screenName,
|
|
95
|
+
});
|
|
96
|
+
const resolvedPath = resolvePathVariables(inferredPath, context.scenarioSteps || []);
|
|
97
|
+
const pathCode = getPathCode(resolvedPath);
|
|
98
|
+
const cleanPath = pathCode.replace(/^['`]|['`]$/g, '');
|
|
99
|
+
const isAbsoluteUrl = /^https?:\/\//.test(cleanPath);
|
|
100
|
+
|
|
101
|
+
if (isThen) {
|
|
102
|
+
return {
|
|
103
|
+
templateName: 'route-assertion',
|
|
104
|
+
data: { path: cleanPath },
|
|
105
|
+
comment: `Assert on ${route}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
templateName: 'navigation',
|
|
111
|
+
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: cleanPath },
|
|
112
|
+
comment: `Navigate to ${route}`,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
priority: 10,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'route-assertion',
|
|
119
|
+
matcher: (step: ParsedStep) =>
|
|
120
|
+
(step.text.includes('should see route') || step.text.includes('should remain on')) &&
|
|
121
|
+
!!(step.selectorRef || step.dataRef),
|
|
122
|
+
resolver: (step, context) => {
|
|
123
|
+
const route = step.selectorRef || step.dataRef;
|
|
124
|
+
|
|
125
|
+
const inferredPath = inferPath(context.featurePath, {
|
|
126
|
+
featureName: context.featureName,
|
|
127
|
+
screenName: context.screenName,
|
|
128
|
+
});
|
|
129
|
+
const resolvedPath = resolvePathVariables(inferredPath, context.scenarioSteps || []);
|
|
130
|
+
const pathCode = getPathCode(resolvedPath);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
templateName: 'route-assertion',
|
|
134
|
+
data: { path: pathCode.replace(/^['`]|['`]$/g, '') },
|
|
135
|
+
comment: `Assert current route is ${route}`,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
priority: 10,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Patterns
|
|
3
|
+
* Handles: switch to [Target] frame, switch to [main] frame
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StepPattern } from '@sun-asterisk/sungen';
|
|
7
|
+
|
|
8
|
+
export const scopePatterns: StepPattern[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'switch-to-frame',
|
|
11
|
+
matcher: (step) => {
|
|
12
|
+
return /\bswitch(?:es)?\s+to\b/i.test(step.text) &&
|
|
13
|
+
(step.elementType === 'frame' || step.elementType === 'iframe');
|
|
14
|
+
},
|
|
15
|
+
resolver: (step, context) => {
|
|
16
|
+
const selectorRef = step.selectorRef || '';
|
|
17
|
+
|
|
18
|
+
// "switch to [main] frame" → reset to page context
|
|
19
|
+
if (selectorRef.toLowerCase() === 'main') {
|
|
20
|
+
return {
|
|
21
|
+
templateName: 'frame-exit-action',
|
|
22
|
+
data: {},
|
|
23
|
+
comment: 'Exit frame scope, return to main page',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// "switch to [Payment] frame" → enter frame scope
|
|
28
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
29
|
+
selectorRef, context.featureName, 'frame', step.nth
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
templateName: 'frame-enter-action',
|
|
34
|
+
data: { ...resolved, frameName: selectorRef },
|
|
35
|
+
comment: `Switch to ${selectorRef} frame`,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
priority: 11,
|
|
39
|
+
},
|
|
40
|
+
];
|