@sungen/driver-ui 3.2.34-beta.1 → 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.
- package/dist/app-profile-presets.d.ts +12 -0
- package/dist/app-profile-presets.d.ts.map +1 -0
- package/dist/app-profile-presets.js +85 -0
- package/dist/app-profile-presets.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/assertion-patterns.d.ts +13 -0
- package/dist/patterns/assertion-patterns.d.ts.map +1 -0
- package/dist/patterns/assertion-patterns.js +903 -0
- package/dist/patterns/assertion-patterns.js.map +1 -0
- package/dist/patterns/capture-patterns.d.ts +21 -0
- package/dist/patterns/capture-patterns.d.ts.map +1 -0
- package/dist/patterns/capture-patterns.js +174 -0
- package/dist/patterns/capture-patterns.js.map +1 -0
- package/dist/patterns/focus-patterns.d.ts +22 -0
- package/dist/patterns/focus-patterns.d.ts.map +1 -0
- package/dist/patterns/focus-patterns.js +79 -0
- package/dist/patterns/focus-patterns.js.map +1 -0
- package/dist/patterns/form-patterns.d.ts +6 -0
- package/dist/patterns/form-patterns.d.ts.map +1 -0
- package/dist/patterns/form-patterns.js +239 -0
- package/dist/patterns/form-patterns.js.map +1 -0
- package/dist/patterns/interaction-patterns.d.ts +20 -0
- package/dist/patterns/interaction-patterns.d.ts.map +1 -0
- package/dist/patterns/interaction-patterns.js +633 -0
- package/dist/patterns/interaction-patterns.js.map +1 -0
- package/dist/patterns/keyboard-patterns.d.ts +7 -0
- package/dist/patterns/keyboard-patterns.d.ts.map +1 -0
- package/dist/patterns/keyboard-patterns.js +57 -0
- package/dist/patterns/keyboard-patterns.js.map +1 -0
- package/dist/patterns/navigation-patterns.d.ts +6 -0
- package/dist/patterns/navigation-patterns.d.ts.map +1 -0
- package/dist/patterns/navigation-patterns.js +185 -0
- package/dist/patterns/navigation-patterns.js.map +1 -0
- package/dist/patterns/scope-patterns.d.ts +7 -0
- package/dist/patterns/scope-patterns.d.ts.map +1 -0
- package/dist/patterns/scope-patterns.js +36 -0
- package/dist/patterns/scope-patterns.js.map +1 -0
- package/dist/patterns/scroll-patterns.d.ts +7 -0
- package/dist/patterns/scroll-patterns.d.ts.map +1 -0
- package/dist/patterns/scroll-patterns.js +25 -0
- package/dist/patterns/scroll-patterns.js.map +1 -0
- package/dist/patterns/setup-patterns.d.ts +6 -0
- package/dist/patterns/setup-patterns.d.ts.map +1 -0
- package/dist/patterns/setup-patterns.js +75 -0
- package/dist/patterns/setup-patterns.js.map +1 -0
- package/dist/patterns/storage-patterns.d.ts +26 -0
- package/dist/patterns/storage-patterns.d.ts.map +1 -0
- package/dist/patterns/storage-patterns.js +142 -0
- package/dist/patterns/storage-patterns.js.map +1 -0
- package/dist/patterns/table-pagination-patterns.d.ts +42 -0
- package/dist/patterns/table-pagination-patterns.d.ts.map +1 -0
- package/dist/patterns/table-pagination-patterns.js +93 -0
- package/dist/patterns/table-pagination-patterns.js.map +1 -0
- package/dist/patterns/table-patterns.d.ts +19 -0
- package/dist/patterns/table-patterns.d.ts.map +1 -0
- package/dist/patterns/table-patterns.js +594 -0
- package/dist/patterns/table-patterns.js.map +1 -0
- package/dist/trace-identity/aria-snapshot-head.d.ts +23 -0
- package/dist/trace-identity/aria-snapshot-head.d.ts.map +1 -0
- package/dist/trace-identity/aria-snapshot-head.js +26 -0
- package/dist/trace-identity/aria-snapshot-head.js.map +1 -0
- package/dist/trace-identity/playwright-identity-resolver.d.ts +10 -0
- package/dist/trace-identity/playwright-identity-resolver.d.ts.map +1 -0
- package/dist/trace-identity/playwright-identity-resolver.js +130 -0
- package/dist/trace-identity/playwright-identity-resolver.js.map +1 -0
- package/dist/trace-identity/trace-run-record-reporter.d.ts +39 -0
- package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
- package/dist/trace-identity/trace-run-record-reporter.js +113 -0
- package/dist/trace-identity/trace-run-record-reporter.js.map +1 -0
- package/dist/utils/column-header.d.ts +37 -0
- package/dist/utils/column-header.d.ts.map +1 -0
- package/dist/utils/column-header.js +46 -0
- package/dist/utils/column-header.js.map +1 -0
- package/dist/utils/row-index.d.ts +18 -0
- package/dist/utils/row-index.d.ts.map +1 -0
- package/dist/utils/row-index.js +39 -0
- package/dist/utils/row-index.js.map +1 -0
- package/dist/utils/url-path-regex.d.ts +45 -0
- package/dist/utils/url-path-regex.d.ts.map +1 -0
- package/dist/utils/url-path-regex.js +68 -0
- package/dist/utils/url-path-regex.js.map +1 -0
- package/package.json +7 -3
- package/src/index.ts +5 -0
- package/src/trace-identity/aria-snapshot-head.ts +33 -0
- package/src/trace-identity/playwright-identity-resolver.ts +123 -0
- 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
|