@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.
@@ -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
+ ];