@sungen/driver-ui 3.2.34 → 3.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/app-profile-presets.d.ts +12 -0
  2. package/dist/app-profile-presets.d.ts.map +1 -0
  3. package/dist/app-profile-presets.js +85 -0
  4. package/dist/app-profile-presets.js.map +1 -0
  5. package/dist/index.d.ts +37 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +93 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/patterns/assertion-patterns.d.ts +13 -0
  10. package/dist/patterns/assertion-patterns.d.ts.map +1 -0
  11. package/dist/patterns/assertion-patterns.js +903 -0
  12. package/dist/patterns/assertion-patterns.js.map +1 -0
  13. package/dist/patterns/capture-patterns.d.ts +21 -0
  14. package/dist/patterns/capture-patterns.d.ts.map +1 -0
  15. package/dist/patterns/capture-patterns.js +174 -0
  16. package/dist/patterns/capture-patterns.js.map +1 -0
  17. package/dist/patterns/focus-patterns.d.ts +22 -0
  18. package/dist/patterns/focus-patterns.d.ts.map +1 -0
  19. package/dist/patterns/focus-patterns.js +79 -0
  20. package/dist/patterns/focus-patterns.js.map +1 -0
  21. package/dist/patterns/form-patterns.d.ts +6 -0
  22. package/dist/patterns/form-patterns.d.ts.map +1 -0
  23. package/dist/patterns/form-patterns.js +239 -0
  24. package/dist/patterns/form-patterns.js.map +1 -0
  25. package/dist/patterns/interaction-patterns.d.ts +20 -0
  26. package/dist/patterns/interaction-patterns.d.ts.map +1 -0
  27. package/dist/patterns/interaction-patterns.js +633 -0
  28. package/dist/patterns/interaction-patterns.js.map +1 -0
  29. package/dist/patterns/keyboard-patterns.d.ts +7 -0
  30. package/dist/patterns/keyboard-patterns.d.ts.map +1 -0
  31. package/dist/patterns/keyboard-patterns.js +57 -0
  32. package/dist/patterns/keyboard-patterns.js.map +1 -0
  33. package/dist/patterns/navigation-patterns.d.ts +6 -0
  34. package/dist/patterns/navigation-patterns.d.ts.map +1 -0
  35. package/dist/patterns/navigation-patterns.js +185 -0
  36. package/dist/patterns/navigation-patterns.js.map +1 -0
  37. package/dist/patterns/scope-patterns.d.ts +7 -0
  38. package/dist/patterns/scope-patterns.d.ts.map +1 -0
  39. package/dist/patterns/scope-patterns.js +36 -0
  40. package/dist/patterns/scope-patterns.js.map +1 -0
  41. package/dist/patterns/scroll-patterns.d.ts +7 -0
  42. package/dist/patterns/scroll-patterns.d.ts.map +1 -0
  43. package/dist/patterns/scroll-patterns.js +25 -0
  44. package/dist/patterns/scroll-patterns.js.map +1 -0
  45. package/dist/patterns/setup-patterns.d.ts +6 -0
  46. package/dist/patterns/setup-patterns.d.ts.map +1 -0
  47. package/dist/patterns/setup-patterns.js +75 -0
  48. package/dist/patterns/setup-patterns.js.map +1 -0
  49. package/dist/patterns/storage-patterns.d.ts +26 -0
  50. package/dist/patterns/storage-patterns.d.ts.map +1 -0
  51. package/dist/patterns/storage-patterns.js +142 -0
  52. package/dist/patterns/storage-patterns.js.map +1 -0
  53. package/dist/patterns/table-pagination-patterns.d.ts +42 -0
  54. package/dist/patterns/table-pagination-patterns.d.ts.map +1 -0
  55. package/dist/patterns/table-pagination-patterns.js +93 -0
  56. package/dist/patterns/table-pagination-patterns.js.map +1 -0
  57. package/dist/patterns/table-patterns.d.ts +19 -0
  58. package/dist/patterns/table-patterns.d.ts.map +1 -0
  59. package/dist/patterns/table-patterns.js +594 -0
  60. package/dist/patterns/table-patterns.js.map +1 -0
  61. package/dist/trace-identity/aria-snapshot-head.d.ts +23 -0
  62. package/dist/trace-identity/aria-snapshot-head.d.ts.map +1 -0
  63. package/dist/trace-identity/aria-snapshot-head.js +26 -0
  64. package/dist/trace-identity/aria-snapshot-head.js.map +1 -0
  65. package/dist/trace-identity/playwright-identity-resolver.d.ts +10 -0
  66. package/dist/trace-identity/playwright-identity-resolver.d.ts.map +1 -0
  67. package/dist/trace-identity/playwright-identity-resolver.js +130 -0
  68. package/dist/trace-identity/playwright-identity-resolver.js.map +1 -0
  69. package/dist/trace-identity/trace-run-record-reporter.d.ts +39 -0
  70. package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
  71. package/dist/trace-identity/trace-run-record-reporter.js +113 -0
  72. package/dist/trace-identity/trace-run-record-reporter.js.map +1 -0
  73. package/dist/utils/column-header.d.ts +37 -0
  74. package/dist/utils/column-header.d.ts.map +1 -0
  75. package/dist/utils/column-header.js +46 -0
  76. package/dist/utils/column-header.js.map +1 -0
  77. package/dist/utils/row-index.d.ts +18 -0
  78. package/dist/utils/row-index.d.ts.map +1 -0
  79. package/dist/utils/row-index.js +39 -0
  80. package/dist/utils/row-index.js.map +1 -0
  81. package/dist/utils/url-path-regex.d.ts +45 -0
  82. package/dist/utils/url-path-regex.d.ts.map +1 -0
  83. package/dist/utils/url-path-regex.js +68 -0
  84. package/dist/utils/url-path-regex.js.map +1 -0
  85. package/package.json +7 -3
  86. package/src/index.ts +10 -1
  87. package/src/trace-identity/aria-snapshot-head.ts +33 -0
  88. package/src/trace-identity/playwright-identity-resolver.ts +123 -0
  89. package/src/trace-identity/trace-run-record-reporter.ts +103 -0
@@ -0,0 +1,633 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.interactionPatterns = void 0;
4
+ exports.parseClickPosition = parseClickPosition;
5
+ const url_path_regex_1 = require("../utils/url-path-regex");
6
+ /**
7
+ * Extract Playwright waitFor state from step text.
8
+ * Defaults to 'visible' if no known state keyword is found.
9
+ */
10
+ function extractWaitState(text) {
11
+ if (/\bhidden\b/.test(text))
12
+ return 'hidden';
13
+ if (/\bdetached\b/.test(text))
14
+ return 'detached';
15
+ if (/\bdisabled\b/.test(text))
16
+ return 'disabled';
17
+ if (/\benabled\b/.test(text))
18
+ return 'enabled';
19
+ if (/\bvisible\b/.test(text))
20
+ return 'visible';
21
+ return 'visible';
22
+ }
23
+ /**
24
+ * Map a plain element keyword (no brackets) to a Playwright ARIA role.
25
+ */
26
+ function elementKeywordToRole(text) {
27
+ 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);
28
+ if (!match)
29
+ return null;
30
+ const keyword = match[1].toLowerCase();
31
+ const roleMap = {
32
+ modal: 'dialog',
33
+ alert: 'alertdialog',
34
+ };
35
+ return roleMap[keyword] || keyword;
36
+ }
37
+ /**
38
+ * Interaction patterns: click, hover, press, wait, alert
39
+ */
40
+ /** Corner presets: 5% in from each corner, so the point stays on the element under any padding. */
41
+ const CLICK_POSITION_PRESETS = {
42
+ 'top-left': { x: 5, y: 5 },
43
+ 'top-right': { x: 95, y: 5 },
44
+ 'bottom-left': { x: 5, y: 95 },
45
+ 'bottom-right': { x: 95, y: 95 },
46
+ };
47
+ /**
48
+ * Parse the `at position "<preset>"` / `at offset x=<n>px|%, y=<n>px|%` suffix. Strict on purpose:
49
+ * anything the grammar does not accept is an error the compiler reports — a position that is half
50
+ * understood would click somewhere the author did not ask for.
51
+ */
52
+ function parseClickPosition(text) {
53
+ const presetNames = Object.keys(CLICK_POSITION_PRESETS).map((k) => `"${k}"`).join(' | ');
54
+ // Both forms end the sentence: anything after the position is text no form reads, so it is refused
55
+ // rather than carried along silently.
56
+ const preset = /\bat\s+position\s+"([^"]*)"\s*$/i.exec(text);
57
+ if (preset) {
58
+ const p = CLICK_POSITION_PRESETS[preset[1].trim().toLowerCase()];
59
+ if (!p)
60
+ return { error: `unknown click position preset "${preset[1]}" — use ${presetNames}` };
61
+ return { x: p.x, xUnit: '%', xRatio: p.x / 100, y: p.y, yUnit: '%', yRatio: p.y / 100 };
62
+ }
63
+ if (/\bat\s+position\b/i.test(text))
64
+ return { error: `\`at position\` needs a quoted preset (${presetNames}) as the last thing in the step` };
65
+ const offset = /\bat\s+offset\s+x\s*=\s*(-?\d+(?:\.\d+)?)\s*(px|%)\s*,\s*y\s*=\s*(-?\d+(?:\.\d+)?)\s*(px|%)\s*$/i.exec(text);
66
+ if (!offset)
67
+ return { error: '`at offset` needs `x=<n>px|%, y=<n>px|%` with both axes (e.g. `x=24px, y=10%`) as the last thing in the step' };
68
+ const axis = (raw, unit, name) => {
69
+ const value = Number(raw);
70
+ if (value < 0)
71
+ return { error: `${name}=${raw}${unit} is negative — offsets are measured from the element's top-left corner` };
72
+ if (unit === '%' && value > 100)
73
+ return { error: `${name}=${raw}% exceeds 100% of the element` };
74
+ return { value, unit: unit.toLowerCase() };
75
+ };
76
+ const x = axis(offset[1], offset[2], 'x');
77
+ if ('error' in x)
78
+ return x;
79
+ const y = axis(offset[3], offset[4], 'y');
80
+ if ('error' in y)
81
+ return y;
82
+ return {
83
+ x: x.value, xUnit: x.unit, xRatio: x.unit === '%' ? x.value / 100 : x.value,
84
+ y: y.value, yUnit: y.unit, yRatio: y.unit === '%' ? y.value / 100 : y.value,
85
+ };
86
+ }
87
+ exports.interactionPatterns = [
88
+ // === Browser Alert (system dialog) patterns ===
89
+ // Compound form (preferred since v3.3): trigger + dialog response in ONE step, in the
90
+ // user's natural order — the compiler emits the listener FIRST (Playwright requires
91
+ // registration before the triggering action; a late handler lets the dialog
92
+ // auto-dismiss with no error):
93
+ // When User click [Đăng xuất] button and accept [OK] alert
94
+ // When User click [Xoá] button and dismiss [Cancel] alert
95
+ {
96
+ name: 'click-with-alert',
97
+ matcher: (step) => /\b(click|clicks)\b/.test(step.text) &&
98
+ /\band\s+(accept|accepts|dismiss|dismisses)\s+\[[^\]]*\]\s+alert\b/i.test(step.text) &&
99
+ (step.text.match(/\[[^\]]+\]/g) || []).length >= 2,
100
+ resolver: (step, context) => {
101
+ // brackets[0] = trigger element, brackets[1] = alert button (verb decides the action)
102
+ const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
103
+ const triggerName = brackets[0].replace(/[\[\]]/g, '');
104
+ const dialogAction = /\band\s+dismiss(?:es)?\b/i.test(step.text) ? 'dismiss' : 'accept';
105
+ // Element type = the word right after the trigger bracket (button/link/…)
106
+ const typeMatch = step.text.match(/\]\s+([\w-]+)\s+and\b/i);
107
+ const triggerType = typeMatch ? typeMatch[1].toLowerCase() : 'button';
108
+ const resolved = context.selectorResolver.resolveSelector(triggerName, context.featureName, triggerType, step.nth);
109
+ return {
110
+ templateName: 'click-with-alert-action',
111
+ data: { ...resolved, dialogAction },
112
+ comment: `Click ${triggerName} and ${dialogAction} the browser dialog`,
113
+ };
114
+ },
115
+ priority: 21,
116
+ },
117
+ // Two-step form (still valid): the alert step must appear BEFORE the click that
118
+ // triggers the alert in Gherkin — SG-W007 warns when it is written after:
119
+ // When User click [OK] alert ← registers page.once('dialog', ...)
120
+ // And User click [Delete] button ← triggers the alert
121
+ {
122
+ name: 'alert-accept',
123
+ matcher: (step) => step.elementType === 'alert' &&
124
+ (step.text.includes('click') || step.text.includes('clicks')) &&
125
+ !step.dataRef &&
126
+ !!(step.selectorRef && /^(ok|accept|yes|confirm)$/i.test(step.selectorRef)),
127
+ resolver: (step, context) => {
128
+ return {
129
+ templateName: 'alert-accept-action',
130
+ data: {},
131
+ comment: `Accept browser alert`,
132
+ };
133
+ },
134
+ priority: 20,
135
+ },
136
+ {
137
+ name: 'alert-dismiss',
138
+ matcher: (step) => step.elementType === 'alert' &&
139
+ (step.text.includes('click') || step.text.includes('clicks')) &&
140
+ !step.dataRef &&
141
+ !!(step.selectorRef && /^(cancel|dismiss|no)$/i.test(step.selectorRef)),
142
+ resolver: (step, context) => {
143
+ return {
144
+ templateName: 'alert-dismiss-action',
145
+ data: {},
146
+ comment: `Dismiss browser alert`,
147
+ };
148
+ },
149
+ priority: 20,
150
+ },
151
+ {
152
+ name: 'alert-fill',
153
+ platforms: ['web'],
154
+ platformAlternative: 'Appium ships no fill for a native prompt - accept or dismiss the alert instead (the `alert` element type still works), drive the app own text field as a normal element, or tag the scenario @manual:M10',
155
+ matcher: (step) => step.elementType === 'alert' &&
156
+ step.text.includes('fill') &&
157
+ !!step.dataRef,
158
+ resolver: (step, context) => {
159
+ let fillValue;
160
+ fillValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
161
+ return {
162
+ templateName: 'alert-fill-action',
163
+ data: { fillValue },
164
+ comment: `Fill browser prompt with ${step.dataRef}`,
165
+ };
166
+ },
167
+ priority: 20,
168
+ },
169
+ {
170
+ // `try to click [X] button` — the DISABLED-element probe (issue: no way to write
171
+ // "click a disabled control and confirm nothing happens").
172
+ //
173
+ // A plain `click` compiles to Playwright's `.click()`, which auto-waits for the element to
174
+ // be ACTIONABLE (visible + enabled + stable). That wait is the whole point for a real click,
175
+ // but it makes the inverse scenario — "the re-send link is in cooldown, clicking it must do
176
+ // nothing" — impossible to express: the step can only ever time out on an element that is
177
+ // disabled BY DESIGN.
178
+ //
179
+ // This variant bypasses the actionability wait (`{ force: true }`), so the click is really
180
+ // dispatched at the element's position. A natively-`disabled` control swallows it and nothing
181
+ // happens (the following assertions prove that); an `aria-disabled`/CSS-only "disabled"
182
+ // control that still handles the click is caught as the bug it is. Note the element must
183
+ // still exist and be rendered — this is not a silent no-op.
184
+ //
185
+ // When User try to click [Re-send] link # cooldown active → must stay inert
186
+ // Then User see [Re-send] link is disabled
187
+ // And User see [Cooldown] text # …and no re-send happened
188
+ name: 'try-click-element',
189
+ matcher: (step) => /\b(?:tr(?:y|ies)|attempts?)\s+to\s+click\b/i.test(step.text) &&
190
+ // `… and accept/dismiss [OK] alert` stays with click-with-alert: force-clicking here would
191
+ // drop the page.once('dialog') listener the compound form exists to register.
192
+ !/\band\s+(?:accept|accepts|dismiss|dismisses)\s+\[[^\]]*\]\s+alert\b/i.test(step.text) &&
193
+ !!step.selectorRef,
194
+ resolver: (step, context) => {
195
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
196
+ // No `with {{v}}` → plain forced click. Mirrors click-element.
197
+ if (!(step.dataRef || step.value) || !step.text.includes('with')) {
198
+ return {
199
+ templateName: 'click-action',
200
+ data: { ...resolved, selectorRef: step.selectorRef, force: true },
201
+ comment: `Try to click ${step.selectorRef} (bypasses the enabled/actionability wait)`,
202
+ };
203
+ }
204
+ // `with {{v}}` → filter to the matching row/item first. Mirrors click-element-with-text.
205
+ let dataValue;
206
+ if (step.value) {
207
+ dataValue = step.value;
208
+ }
209
+ else {
210
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
211
+ }
212
+ const { name: _name, ...resolvedWithoutName } = resolved;
213
+ return {
214
+ templateName: 'click-element-with-text',
215
+ data: { ...resolvedWithoutName, dataValue, force: true },
216
+ comment: `Try to click ${step.selectorRef} with text ${step.dataRef || step.value} (bypasses the enabled/actionability wait)`,
217
+ };
218
+ },
219
+ priority: 22, // above click-with-alert (21) and every other click pattern — the verb wins
220
+ },
221
+ {
222
+ name: 'unknown-element-action',
223
+ matcher: (step) => {
224
+ const isTargetElement = step.selectorRef && /^target\s/i.test(step.selectorRef);
225
+ const isInteractionAction = !step.text.includes('see') && !step.text.includes('sees');
226
+ return isTargetElement && !!step.dataRef && step.text.includes('with') && isInteractionAction;
227
+ },
228
+ resolver: (step, context) => {
229
+ let selectorValue = '';
230
+ let nth;
231
+ let exact = false;
232
+ try {
233
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
234
+ selectorValue = resolved.value || '';
235
+ nth = resolved.nth;
236
+ exact = resolved.exact === true;
237
+ }
238
+ catch (error) {
239
+ selectorValue = '';
240
+ nth = undefined;
241
+ }
242
+ let dataValue;
243
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
244
+ let actionMethod = 'click';
245
+ if (step.text.includes('hover'))
246
+ actionMethod = 'hover';
247
+ else if (step.text.includes('double'))
248
+ actionMethod = 'dblclick';
249
+ return {
250
+ templateName: 'unknown-element-action',
251
+ data: { selectorValue, dataValue, action: actionMethod, nth, exact },
252
+ comment: `${actionMethod.charAt(0).toUpperCase() + actionMethod.slice(1)} ${step.selectorRef} with ${step.dataRef}`,
253
+ };
254
+ },
255
+ priority: 14, // Below click-element-with-text (15) so known selectors use proper locator
256
+ },
257
+ {
258
+ // `click [X] overlay at position "top-left"` / `… at offset x=24px, y=10%` — a click AWAY from the
259
+ // centre, for backdrop-dismiss overlays and any control whose centre is covered by a child.
260
+ // `.click()` always lands on the centre; there was no way to say "click the overlay, not the
261
+ // dialog sitting in the middle of it". Presets are 5% in from the corners so the point stays on
262
+ // the element under any padding. Positions are checked against the element's PADDING box at
263
+ // run time (`clientWidth/Height`, the same box `click({ position })` measures from), so a px
264
+ // offset beyond the real size fails with the real size in the message instead of clicking the
265
+ // neighbour. A suffix the grammar cannot parse is refused at compile time (SG-E026, via the core
266
+ // guard) — never a silent centre click.
267
+ name: 'click-element-at-position',
268
+ platforms: ['web'],
269
+ platformAlternative: 'Appium has no positioned click on an element — tap the control the position targets directly (`User tap [X] button`), or tag the scenario @manual',
270
+ matcher: (step) => /\b(click|clicks)\b/.test(step.text)
271
+ // a positioned DOUBLE/RIGHT click is not offered — leave those to their own forms so the core
272
+ // guard refuses the suffix instead of this form silently turning them into a single click
273
+ && !/\b(?:double|right)[\s-]*click/i.test(step.text)
274
+ && /\bat\s+(position|offset)\b/i.test(step.text) && !!step.selectorRef,
275
+ resolver: (step, context) => {
276
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
277
+ const suffix = step.text.match(/\bat\s+(?:position|offset)\b.*$/i)?.[0].trim() ?? 'at position';
278
+ // JSON.stringify quotes the label for the generated code — the step text is author text.
279
+ const label = JSON.stringify(`click [${step.selectorRef}] ${suffix}`);
280
+ const parsed = parseClickPosition(step.text);
281
+ if ('error' in parsed) {
282
+ // The template is not what refuses this — the core compiler does (SG-E026 + a loud throw);
283
+ // `positionError` is the signal it reads. Emitted here so the reason names the exact fault.
284
+ return {
285
+ templateName: 'click-at-position-action',
286
+ data: { ...resolved, selectorRef: step.selectorRef, label, positionError: parsed.error },
287
+ comment: `Click ${step.selectorRef} ${suffix}`,
288
+ };
289
+ }
290
+ return {
291
+ templateName: 'click-at-position-action',
292
+ data: { ...resolved, selectorRef: step.selectorRef, label, clickPosition: parsed },
293
+ comment: `Click ${step.selectorRef} ${suffix}`,
294
+ };
295
+ },
296
+ priority: 12, // above click-element (10): the suffix wins; below click-element-with-text (15) and the
297
+ // alert/try-to forms — those combinations are refused by the core guard, not compiled
298
+ },
299
+ {
300
+ name: 'click-element-with-text',
301
+ matcher: (step) => (step.text.includes('clicks') || step.text.includes('click'))
302
+ && !!step.selectorRef
303
+ && !!(step.dataRef || step.value)
304
+ && step.text.includes('with'),
305
+ resolver: (step, context) => {
306
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
307
+ let dataValue;
308
+ if (step.value) {
309
+ dataValue = step.value;
310
+ }
311
+ else {
312
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
313
+ }
314
+ const { name: _name, ...resolvedWithoutName } = resolved;
315
+ return {
316
+ templateName: 'click-element-with-text',
317
+ data: { ...resolvedWithoutName, dataValue },
318
+ comment: `Click ${step.selectorRef} with text ${step.dataRef || step.value}`,
319
+ };
320
+ },
321
+ priority: 15,
322
+ },
323
+ {
324
+ name: 'click-element',
325
+ matcher: (step) => (step.text.includes('clicks') || step.text.includes('click')) && !!step.selectorRef,
326
+ resolver: (step, context) => {
327
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
328
+ return {
329
+ templateName: 'click-action',
330
+ data: { ...resolved, selectorRef: step.selectorRef },
331
+ comment: `Click ${step.selectorRef}`,
332
+ };
333
+ },
334
+ priority: 10,
335
+ },
336
+ {
337
+ name: 'double-click',
338
+ matcher: (step) => (step.text.includes('double click') || step.text.includes('double-click') ||
339
+ (step.text.includes('double') && step.text.includes('clicks'))) && !!step.selectorRef,
340
+ resolver: (step, context) => {
341
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
342
+ return {
343
+ templateName: 'double-click-action',
344
+ data: { ...resolved, selectorRef: step.selectorRef },
345
+ comment: `Double click ${step.selectorRef}`,
346
+ };
347
+ },
348
+ priority: 11,
349
+ },
350
+ {
351
+ // `hover [X] item with {{v}}` — pick the element whose text matches the value, mirroring
352
+ // click-element-with-text. Without this, hover fell through to hover-element which IGNORED
353
+ // the value and hovered the first match (strict-mode / wrong element on dynamic lists).
354
+ name: 'hover-element-with-text',
355
+ // Pure: this resolver requests `hover-element-with-text` and nothing else, which is what makes
356
+ // the declaration safe. A pattern that can reach a shared template (click, recipe-sequence) must
357
+ // NOT be declared — see the mixed `unknown-element-action` below, which also serves click.
358
+ platforms: ['web'],
359
+ platformAlternative: 'a touch screen has no hover, so filtering a list by text and hovering it has no mobile meaning — tap the matching item with `User tap [X] item with {{value}}`, or tag the scenario @manual:M10',
360
+ matcher: (step) => (step.text.includes('hovers') || step.text.match(/\bhover\b/))
361
+ && !!step.selectorRef
362
+ && !!(step.dataRef || step.value)
363
+ && step.text.includes('with'),
364
+ resolver: (step, context) => {
365
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
366
+ let dataValue;
367
+ if (step.value) {
368
+ dataValue = step.value;
369
+ }
370
+ else {
371
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
372
+ }
373
+ const { name: _name, ...resolvedWithoutName } = resolved;
374
+ return {
375
+ templateName: 'hover-element-with-text',
376
+ data: { ...resolvedWithoutName, dataValue },
377
+ comment: `Hover ${step.selectorRef} with text ${step.dataRef || step.value}`,
378
+ };
379
+ },
380
+ priority: 9, // above hover-element (8) so `with {{v}}` filters instead of hovering the first
381
+ },
382
+ {
383
+ name: 'hover-element',
384
+ // Pure: requests `hover-action` only.
385
+ platforms: ['web'],
386
+ platformAlternative: 'there is no hover on a touch screen — content a hover reveals is normally already visible on mobile; if the interaction is genuinely required, tap it with `User tap [X]`, otherwise tag the scenario @manual:M10',
387
+ matcher: (step) => (step.text.includes('hovers') || step.text.match(/\bhover\b/)) && !!step.selectorRef,
388
+ resolver: (step, context) => {
389
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
390
+ return {
391
+ templateName: 'hover-action',
392
+ data: { ...resolved, selectorRef: step.selectorRef },
393
+ comment: `Hover ${step.selectorRef}`,
394
+ };
395
+ },
396
+ priority: 8,
397
+ },
398
+ {
399
+ name: 'click-by-data-text',
400
+ matcher: (step) => (step.text.includes('clicks') || step.text.includes('click')) && !step.selectorRef && !!step.dataRef,
401
+ resolver: (step, context) => {
402
+ let value;
403
+ value = context.dataResolver.resolveData(step.dataRef, context.featureName);
404
+ return {
405
+ templateName: 'click-action',
406
+ data: { strategy: 'text', value },
407
+ comment: `Click ${step.dataRef}`,
408
+ };
409
+ },
410
+ priority: 5,
411
+ },
412
+ {
413
+ name: 'wait-for-time',
414
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) && /\d+/.test(step.text),
415
+ resolver: (step, context) => {
416
+ const match = step.text.match(/(\d+)\s*(seconds?|ms|milliseconds?)/);
417
+ let duration = 1000;
418
+ if (match) {
419
+ const num = parseInt(match[1]);
420
+ const unit = match[2].toLowerCase();
421
+ duration = unit.startsWith('s') ? num * 1000 : num;
422
+ }
423
+ return {
424
+ templateName: 'wait-timeout',
425
+ data: { duration },
426
+ comment: `Wait for ${duration}ms`,
427
+ };
428
+ },
429
+ priority: 7,
430
+ },
431
+ {
432
+ name: 'wait-for-role-with-text',
433
+ // Shares `extractWaitState()` with wait-for-element — same four, same reason for the
434
+ // omissions.
435
+ states: ['hidden', 'disabled', 'enabled', 'visible'],
436
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) &&
437
+ !step.selectorRef &&
438
+ !!step.dataRef &&
439
+ !!elementKeywordToRole(step.text),
440
+ resolver: (step, context) => {
441
+ const role = elementKeywordToRole(step.text);
442
+ let dataValue;
443
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
444
+ const state = extractWaitState(step.text);
445
+ return {
446
+ templateName: 'wait-for-role-with-data',
447
+ data: { role, dataValue, state },
448
+ comment: `Wait for ${role} with ${step.dataRef} to be ${state}`,
449
+ };
450
+ },
451
+ priority: 11,
452
+ },
453
+ {
454
+ name: 'wait-for-role',
455
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) &&
456
+ !step.selectorRef &&
457
+ !step.dataRef &&
458
+ !!elementKeywordToRole(step.text),
459
+ resolver: (step, context) => {
460
+ const role = elementKeywordToRole(step.text);
461
+ const state = extractWaitState(step.text);
462
+ return {
463
+ templateName: 'wait-for-role',
464
+ data: { role, state },
465
+ comment: `Wait for ${role} to be ${state}`,
466
+ };
467
+ },
468
+ priority: 11,
469
+ },
470
+ {
471
+ name: 'wait-for-page',
472
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) && step.elementType === 'page',
473
+ resolver: (step, context) => {
474
+ // Fallback is the feature's declared Path (context.featurePath) — `step.featurePath` is
475
+ // never populated by the parser, so it always degraded to '/'.
476
+ const fallbackPath = context.featurePath || '/';
477
+ let path = fallbackPath;
478
+ if (step.selectorRef) {
479
+ try {
480
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
481
+ // Only a `page`-type entry carries a URL in `value`. A key collision with a non-page
482
+ // entry (e.g. a role/button sharing the key) must fall back to featurePath, never feed
483
+ // its value ('button') into waitForURL. See page-assertion for the mirror guard.
484
+ path = resolved.selectorType === 'page' ? (resolved.value || fallbackPath) : fallbackPath;
485
+ }
486
+ catch (error) {
487
+ // fallback to featurePath
488
+ }
489
+ }
490
+ // Host and path are matched SEPARATELY since #677 — see urlToPatternParts, which now serves
491
+ // every URL pattern in the compiler so the two assertion forms cannot drift from this wait.
492
+ return {
493
+ templateName: 'wait-for-page',
494
+ data: (0, url_path_regex_1.urlToPatternParts)(path),
495
+ comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
496
+ };
497
+ },
498
+ priority: 9,
499
+ },
500
+ {
501
+ // FR3 (issue #468): `User wait for [T] table to refresh` — the dedicated wait for
502
+ // filter/search/pagination round-trips. Signal = the app profile's loading indicator
503
+ // (qa/app.yaml feedback.loading.indicator, default [aria-busy="true"]): soft-wait for
504
+ // it to appear (the spinner may already be gone), hard-wait for it to disappear.
505
+ name: 'wait-table-refresh',
506
+ matcher: (step) => /\bwait(?:s)?\s+for\b/i.test(step.text) && /\btable\s+to\s+refresh\b/i.test(step.text),
507
+ resolver: (step, context) => ({
508
+ templateName: 'wait-table-refresh',
509
+ data: { indicator: context.appProfile?.loadingIndicator ?? '[aria-busy="true"]' },
510
+ comment: `Wait for ${step.selectorRef || 'table'} to refresh`,
511
+ }),
512
+ priority: 12,
513
+ },
514
+ {
515
+ name: 'wait-for-element-with-text',
516
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) &&
517
+ !!step.selectorRef &&
518
+ !!step.dataRef,
519
+ resolver: (step, context) => {
520
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
521
+ let dataValue;
522
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
523
+ const state = extractWaitState(step.text);
524
+ return {
525
+ templateName: 'wait-for-element-with-text',
526
+ data: { ...resolved, selectorRef: step.selectorRef, dataValue, state },
527
+ comment: `Wait for ${step.selectorRef} with ${step.dataRef} to be ${state}`,
528
+ };
529
+ },
530
+ priority: 10,
531
+ },
532
+ {
533
+ name: 'wait-for-element',
534
+ // Exactly what `extractWaitState()` resolves. Every other clause falls back to 'visible', i.e.
535
+ // is silently dropped — so it is deliberately NOT declared and now reports SG-E021.
536
+ states: ['hidden', 'disabled', 'enabled', 'visible'],
537
+ matcher: (step) => (step.text.includes('wait for') || step.text.includes('waits for')) && !!step.selectorRef,
538
+ resolver: (step, context) => {
539
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
540
+ const state = extractWaitState(step.text);
541
+ return {
542
+ templateName: 'wait-for-element',
543
+ data: { ...resolved, selectorRef: step.selectorRef, state },
544
+ comment: `Wait for ${step.selectorRef} to be ${state}`,
545
+ };
546
+ },
547
+ priority: 8,
548
+ },
549
+ {
550
+ name: 'toggle-switch',
551
+ // Matches the unconditional-flip verb "toggle(s)" — e.g. `When User toggles [X]`. Steps that
552
+ // say `check`/`uncheck [X] toggle` also contain the word "toggle" (as the element-type suffix)
553
+ // but express a desired on/off STATE, not a blind flip — those must fall through to the
554
+ // idempotent check-checkbox/uncheck-checkbox patterns below instead (priority 8), so excluding
555
+ // the check/uncheck verbs here is what makes that routing possible.
556
+ matcher: (step) => (step.text.includes('toggles') || step.text.match(/\btoggle\b/)) &&
557
+ !/\b(checks?|unchecks?)\b/.test(step.text) &&
558
+ !!step.selectorRef,
559
+ resolver: (step, context) => {
560
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
561
+ return {
562
+ templateName: 'toggle-action',
563
+ data: { ...resolved, selectorRef: step.selectorRef },
564
+ comment: `Toggle ${step.selectorRef}`,
565
+ };
566
+ },
567
+ priority: 9,
568
+ },
569
+ {
570
+ name: 'drag-to',
571
+ matcher: (step) => (step.text.includes('drags') || step.text.match(/\bdrag\b/)) &&
572
+ /\bto\s+\[/.test(step.text) && !!step.selectorRef,
573
+ resolver: (step, context) => {
574
+ // Extract both selector refs: [Source] ... to [Target]
575
+ const allRefs = step.text.match(/\[([^\]]+)\]/g);
576
+ if (!allRefs || allRefs.length < 2) {
577
+ return {
578
+ templateName: 'drag-action',
579
+ data: { strategy: 'text', value: step.selectorRef, targetStrategy: 'locator', targetValue: 'TODO' },
580
+ comment: `Drag ${step.selectorRef} (missing target)`,
581
+ };
582
+ }
583
+ const sourceRef = allRefs[0].slice(1, -1);
584
+ const targetRef = allRefs[1].slice(1, -1);
585
+ const sourceResolved = context.selectorResolver.resolveSelector(sourceRef, undefined, step.elementType, step.nth);
586
+ const targetResolved = context.selectorResolver.resolveSelector(targetRef, undefined, undefined, 0);
587
+ // C3 — pass the resolved TARGET selector FIELDS, not a built Playwright locator string.
588
+ // Each adapter's own drag-action template renders the target locator for its own
589
+ // framework (Playwright: page.getByRole/getByText/...; Appium: an accessibility-id/xpath
590
+ // $() lookup — it already consumes this exact shape).
591
+ return {
592
+ templateName: 'drag-action',
593
+ data: {
594
+ ...sourceResolved,
595
+ targetStrategy: targetResolved.strategy,
596
+ targetValue: targetResolved.value,
597
+ targetName: targetResolved.name,
598
+ targetRole: targetResolved.role,
599
+ targetRef,
600
+ },
601
+ comment: `Drag ${sourceRef} to ${targetRef}`,
602
+ };
603
+ },
604
+ priority: 10,
605
+ },
606
+ {
607
+ name: 'expand-element',
608
+ matcher: (step) => (step.text.includes('expands') || step.text.match(/\bexpand\b/)) && !!step.selectorRef,
609
+ resolver: (step, context) => {
610
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
611
+ return {
612
+ templateName: 'expand-action',
613
+ data: { ...resolved, selectorRef: step.selectorRef, direction: 'expand' },
614
+ comment: `Expand ${step.selectorRef}`,
615
+ };
616
+ },
617
+ priority: 9,
618
+ },
619
+ {
620
+ name: 'collapse-element',
621
+ matcher: (step) => (step.text.includes('collapses') || step.text.match(/\bcollapse\b/)) && !!step.selectorRef,
622
+ resolver: (step, context) => {
623
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
624
+ return {
625
+ templateName: 'expand-action',
626
+ data: { ...resolved, selectorRef: step.selectorRef, direction: 'collapse' },
627
+ comment: `Collapse ${step.selectorRef}`,
628
+ };
629
+ },
630
+ priority: 9,
631
+ },
632
+ ];
633
+ //# sourceMappingURL=interaction-patterns.js.map