@sun-asterisk/sungen 1.0.22 → 1.0.24

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 (61) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/core/live-scanner/element-finder.d.ts +2 -2
  3. package/dist/core/live-scanner/element-finder.d.ts.map +1 -1
  4. package/dist/core/live-scanner/element-finder.js +192 -116
  5. package/dist/core/live-scanner/element-finder.js.map +1 -1
  6. package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -1
  7. package/dist/core/live-scanner/matrix-reader.js +1 -0
  8. package/dist/core/live-scanner/matrix-reader.js.map +1 -1
  9. package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -1
  10. package/dist/core/live-scanner/matrix-writer.js +1 -0
  11. package/dist/core/live-scanner/matrix-writer.js.map +1 -1
  12. package/dist/core/live-scanner/role-fallback.d.ts.map +1 -1
  13. package/dist/core/live-scanner/role-fallback.js +1 -0
  14. package/dist/core/live-scanner/role-fallback.js.map +1 -1
  15. package/dist/core/live-scanner/scanner.d.ts +9 -0
  16. package/dist/core/live-scanner/scanner.d.ts.map +1 -1
  17. package/dist/core/live-scanner/scanner.js +68 -8
  18. package/dist/core/live-scanner/scanner.js.map +1 -1
  19. package/dist/core/live-scanner/step-replayer.d.ts +1 -1
  20. package/dist/core/live-scanner/step-replayer.d.ts.map +1 -1
  21. package/dist/core/live-scanner/step-replayer.js +194 -6
  22. package/dist/core/live-scanner/step-replayer.js.map +1 -1
  23. package/dist/core/live-scanner/types.d.ts +3 -2
  24. package/dist/core/live-scanner/types.d.ts.map +1 -1
  25. package/dist/generators/scaffold-generator/index.d.ts +1 -0
  26. package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
  27. package/dist/generators/scaffold-generator/index.js +37 -9
  28. package/dist/generators/scaffold-generator/index.js.map +1 -1
  29. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
  30. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
  31. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
  32. package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
  33. package/dist/generators/test-generator/patterns/assertion-patterns.js +31 -8
  34. package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
  35. package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
  36. package/dist/generators/test-generator/patterns/form-patterns.js +8 -2
  37. package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
  38. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  39. package/dist/generators/test-generator/step-mapper.js +4 -2
  40. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  41. package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
  42. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  43. package/dist/generators/test-generator/utils/selector-resolver.js +21 -17
  44. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/cli/index.ts +1 -1
  47. package/src/core/live-scanner/element-finder.ts +198 -118
  48. package/src/core/live-scanner/matrix-reader.ts +1 -0
  49. package/src/core/live-scanner/matrix-writer.ts +1 -0
  50. package/src/core/live-scanner/role-fallback.ts +1 -0
  51. package/src/core/live-scanner/scanner.ts +76 -9
  52. package/src/core/live-scanner/step-replayer.ts +204 -6
  53. package/src/core/live-scanner/types.ts +3 -2
  54. package/src/generators/scaffold-generator/index.ts +39 -9
  55. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-editor-action.hbs +3 -0
  56. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +2 -0
  57. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/locator.hbs +1 -1
  58. package/src/generators/test-generator/patterns/assertion-patterns.ts +37 -8
  59. package/src/generators/test-generator/patterns/form-patterns.ts +9 -2
  60. package/src/generators/test-generator/step-mapper.ts +4 -2
  61. package/src/generators/test-generator/utils/selector-resolver.ts +25 -17
@@ -23,6 +23,19 @@ interface ReplayResult {
23
23
  errors: string[];
24
24
  }
25
25
 
26
+ /**
27
+ * Resolve a {{dataRef}} path against test-data object.
28
+ * Supports dot-notation: "email.valid" → testData.email.valid
29
+ */
30
+ function resolveDataRef(testData: Record<string, any>, dataRef: string): string {
31
+ const parts = dataRef.replace(/[{}]/g, '').split('.');
32
+ let value: any = testData;
33
+ for (const part of parts) {
34
+ value = value?.[part];
35
+ }
36
+ return typeof value === 'string' ? value : String(value ?? '');
37
+ }
38
+
26
39
  /**
27
40
  * Replay a sequence of Gherkin steps on a Playwright page,
28
41
  * finding and interacting with elements along the way.
@@ -32,7 +45,8 @@ export async function replaySteps(
32
45
  steps: ParsedStepInfo[],
33
46
  featurePath: string,
34
47
  baseUrl: string,
35
- existingElements?: Record<string, LiveElement>
48
+ existingElements?: Record<string, LiveElement>,
49
+ testData?: Record<string, any> | null
36
50
  ): Promise<ReplayResult> {
37
51
  const elements: Record<string, LiveElement> = {};
38
52
  const errors: string[] = [];
@@ -80,6 +94,7 @@ export async function replaySteps(
80
94
  name: step.selectorRef,
81
95
  testid: null,
82
96
  tag: '',
97
+ contenteditable: false,
83
98
  placeholder: null,
84
99
  label: null,
85
100
  context: 'page',
@@ -112,7 +127,7 @@ export async function replaySteps(
112
127
  (elements[key] as any).skipped = true;
113
128
  console.log(` ⏩ [${step.selectorRef}] ${elementType} → cached (${existingElements[key].selectorType})`);
114
129
 
115
- // Still execute clicks for cached elements to maintain page state
130
+ // Still execute actions for cached elements to maintain page state
116
131
  if (action === 'click') {
117
132
  try {
118
133
  await executeClick(page, step.selectorRef, elementType, nth);
@@ -121,12 +136,32 @@ export async function replaySteps(
121
136
  } catch {
122
137
  // Click failed for cached element, continue anyway
123
138
  }
139
+ } else if (action === 'fill' && testData && step.dataRef) {
140
+ try {
141
+ const value = resolveDataRef(testData, step.dataRef);
142
+ if (value) {
143
+ await executeFill(page, step.selectorRef, elementType, nth, value);
144
+ await settle(page);
145
+ }
146
+ } catch {
147
+ // Fill failed for cached element, continue anyway
148
+ }
149
+ } else if (action === 'select' && testData && step.dataRef) {
150
+ try {
151
+ const value = resolveDataRef(testData, step.dataRef);
152
+ if (value) {
153
+ await executeSelect(page, step.selectorRef, elementType, nth, value);
154
+ await settle(page);
155
+ }
156
+ } catch {
157
+ // Select failed for cached element, continue anyway
158
+ }
124
159
  }
125
160
  continue;
126
161
  }
127
162
 
128
- // Find the element on the page
129
- const element = await findElement(page, step.selectorRef, elementType, nth);
163
+ // Find the element on the page, scoped to current context (dialog/page)
164
+ const element = await findElement(page, step.selectorRef, elementType, nth, currentContext);
130
165
  element.context = currentContext;
131
166
 
132
167
  // Record in matrix (first match wins for duplicate keys)
@@ -141,11 +176,75 @@ export async function replaySteps(
141
176
  await settle(page);
142
177
  currentContext = await detectCurrentContext(page, currentContext, step.text);
143
178
  } else if (action === 'fill') {
144
- // For fill actions during scanning, use a placeholder value
145
- // We just need to verify the element exists
179
+ if (testData && step.dataRef) {
180
+ const value = resolveDataRef(testData, step.dataRef);
181
+ if (value) {
182
+ try {
183
+ await executeFill(page, step.selectorRef, elementType, nth, value);
184
+ await settle(page);
185
+ } catch (e: any) {
186
+ errors.push(`Step "${step.text}": fill failed — ${e.message}`);
187
+ }
188
+ }
189
+ }
190
+ } else if (action === 'select') {
191
+ if (testData && step.dataRef) {
192
+ const value = resolveDataRef(testData, step.dataRef);
193
+ if (value) {
194
+ try {
195
+ await executeSelect(page, step.selectorRef, elementType, nth, value);
196
+ await settle(page);
197
+ } catch (e: any) {
198
+ errors.push(`Step "${step.text}": select failed — ${e.message}`);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ // Label-value detection: check if the data value appears in a sibling element
204
+ if (action === 'see' && normalizedType === 'text' && (elementType === 'label' || elementType === 'title' || elementType === 'caption') && testData && step.dataRef) {
205
+ const dataValue = resolveDataRef(testData, step.dataRef);
206
+ if (dataValue) {
207
+ try {
208
+ // Check if the parent container has both the label text and the data value
209
+ const hasLabelValue = await page.getByText(new RegExp(escapeRegex(element.gherkinRef) + '.*' + escapeRegex(dataValue))).first().isVisible({ timeout: 2000 });
210
+ if (hasLabelValue) {
211
+ element.matchMethod = 'label-value';
212
+ elements[key] = element;
213
+ }
214
+ } catch {
215
+ // Regex match failed, keep original matchMethod
216
+ }
217
+ }
146
218
  }
147
219
  // 'see' and 'wait' actions: just record, don't interact
148
220
  } else {
221
+ // Try data-value fallback for click actions: use the resolved data value as element name
222
+ if (action === 'click' && testData && step.dataRef) {
223
+ const dataValue = resolveDataRef(testData, step.dataRef);
224
+ if (dataValue) {
225
+ // Brief wait for dynamic content (e.g. autocomplete dropdown after fill)
226
+ await page.waitForTimeout(1000);
227
+ const fallback = await findElement(page, dataValue, elementType, nth, currentContext);
228
+ if (fallback.matchMethod !== 'unresolved') {
229
+ // Preserve original gherkinRef; clear locator values since they come
230
+ // from test-data ({{dataRef}}) at runtime, not hardcoded from DOM.
231
+ // Store dataValue for logging in scanner.ts
232
+ fallback.gherkinRef = step.selectorRef;
233
+ (fallback as any).dataValue = dataValue;
234
+ fallback.name = '';
235
+ fallback.exact = false;
236
+ elements[key] = fallback;
237
+ try {
238
+ await executeClick(page, dataValue, elementType, nth);
239
+ await settle(page);
240
+ currentContext = await detectCurrentContext(page, currentContext, step.text);
241
+ } catch {
242
+ // Click failed, element still recorded
243
+ }
244
+ continue;
245
+ }
246
+ }
247
+ }
149
248
  errors.push(`Step "${step.text}": element [${step.selectorRef}] not found`);
150
249
  // Continue scanning remaining steps — record what we can even if some elements are missing
151
250
  }
@@ -198,6 +297,104 @@ async function executeClick(
198
297
  }
199
298
  }
200
299
 
300
+ async function executeFill(
301
+ page: Page,
302
+ ref: string,
303
+ elementType: string,
304
+ nth: number,
305
+ value: string
306
+ ): Promise<void> {
307
+ const namePattern = new RegExp(escapeRegex(ref), 'i');
308
+
309
+ // Try placeholder first — most direct for input fields, avoids slow role attempts
310
+ try {
311
+ const locator = page.getByPlaceholder(namePattern);
312
+ const target = nth > 0 ? locator.nth(nth) : locator.first();
313
+ if (await target.isVisible({ timeout: 3000 })) {
314
+ await target.fill(value, { timeout: 5000 });
315
+ return;
316
+ }
317
+ } catch {
318
+ // continue
319
+ }
320
+
321
+ // Try label
322
+ try {
323
+ const locator = page.getByLabel(namePattern);
324
+ const target = nth > 0 ? locator.nth(nth) : locator.first();
325
+ if (await target.isVisible({ timeout: 3000 })) {
326
+ await target.fill(value, { timeout: 5000 });
327
+ return;
328
+ }
329
+ } catch {
330
+ // continue
331
+ }
332
+
333
+ // Try role-based fill (textbox, combobox, spinbutton)
334
+ const { getRoleFallbacks } = require('./role-fallback');
335
+ const roles = getRoleFallbacks(elementType);
336
+
337
+ for (const role of roles) {
338
+ try {
339
+ const locator = page.getByRole(role as any, { name: namePattern });
340
+ const target = nth > 0 ? locator.nth(nth) : locator.first();
341
+ if (await target.isVisible({ timeout: 3000 })) {
342
+ await target.fill(value, { timeout: 5000 });
343
+ return;
344
+ }
345
+ } catch {
346
+ continue;
347
+ }
348
+ }
349
+
350
+ // Try testid as last resort
351
+ const normalized = ref.toLowerCase().replace(/[\s_-]+/g, '-');
352
+ await page.locator(`[data-testid*="${normalized}" i]`).first().fill(value, { timeout: 5000 });
353
+ }
354
+
355
+ async function executeSelect(
356
+ page: Page,
357
+ ref: string,
358
+ elementType: string,
359
+ nth: number,
360
+ value: string
361
+ ): Promise<void> {
362
+ const namePattern = new RegExp(escapeRegex(ref), 'i');
363
+
364
+ // Try label first — most direct for select elements
365
+ try {
366
+ const locator = page.getByLabel(namePattern);
367
+ const target = nth > 0 ? locator.nth(nth) : locator.first();
368
+ if (await target.isVisible({ timeout: 3000 })) {
369
+ await target.selectOption(value, { timeout: 5000 });
370
+ return;
371
+ }
372
+ } catch {
373
+ // continue
374
+ }
375
+
376
+ // Try role-based select (combobox, listbox)
377
+ const { getRoleFallbacks } = require('./role-fallback');
378
+ const roles = getRoleFallbacks(elementType);
379
+
380
+ for (const role of roles) {
381
+ try {
382
+ const locator = page.getByRole(role as any, { name: namePattern });
383
+ const target = nth > 0 ? locator.nth(nth) : locator.first();
384
+ if (await target.isVisible({ timeout: 3000 })) {
385
+ await target.selectOption(value, { timeout: 5000 });
386
+ return;
387
+ }
388
+ } catch {
389
+ continue;
390
+ }
391
+ }
392
+
393
+ // Try testid as last resort
394
+ const normalized = ref.toLowerCase().replace(/[\s_-]+/g, '-');
395
+ await page.locator(`[data-testid*="${normalized}" i]`).first().selectOption(value, { timeout: 5000 });
396
+ }
397
+
201
398
  function detectAction(text: string): string {
202
399
  const lower = text.toLowerCase();
203
400
  if (/\b(is on|navigate|go to|visit|open)\b/.test(lower) && /\bpage\b/.test(lower)) return 'navigate';
@@ -287,6 +484,7 @@ function normalizeElementType(elementType: string, action: string): string {
287
484
  if (t === 'element') return 'element';
288
485
  if (t === 'column' || t === 'columnheader') return 'column';
289
486
  if (t === 'logo' || t === 'image' || t === 'img' || t === 'icon') return 'img';
487
+ if (t === 'option' || t === 'item' || t === 'listitem') return 'option';
290
488
  if (t === 'dialog' || t === 'modal') return 'dialog';
291
489
  if (t === 'heading' || t === 'header') return 'heading';
292
490
  if (t === 'title' || t === 'caption' || t === 'message') return 'text';
@@ -10,9 +10,9 @@
10
10
  * text → page.getByText(value)
11
11
  */
12
12
 
13
- export type MatchMethod = 'testid' | 'exact_role' | 'role_fallback' | 'label' | 'placeholder' | 'text_only' | 'unresolved';
13
+ export type MatchMethod = 'testid' | 'exact_role' | 'role_fallback' | 'label' | 'placeholder' | 'text_only' | 'contenteditable' | 'label-value' | 'unresolved';
14
14
 
15
- export type SelectorType = 'testid' | 'role' | 'label' | 'placeholder' | 'text';
15
+ export type SelectorType = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'locator';
16
16
 
17
17
  export type ElementContext = 'page' | 'dialog' | 'menu' | 'modal' | 'popup';
18
18
 
@@ -25,6 +25,7 @@ export interface LiveElement {
25
25
  name: string;
26
26
  testid: string | null;
27
27
  tag: string;
28
+ contenteditable: boolean;
28
29
  placeholder: string | null;
29
30
  label: string | null;
30
31
  context: ElementContext;
@@ -17,6 +17,7 @@ export interface ScaffoldElement {
17
17
  name?: string; // For role type (accessible name), or label text
18
18
  nth: number;
19
19
  exact?: boolean; // Whether to use exact matching (default: false)
20
+ inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
20
21
  }
21
22
 
22
23
  export interface ScaffoldResult {
@@ -165,6 +166,9 @@ export class ScaffoldGenerator {
165
166
  if (/^textarea\b/.test(lowerText) || /^\d*\s*textarea\b/.test(lowerText)) {
166
167
  return 'textarea';
167
168
  }
169
+ if (/^editor\b/.test(lowerText) || /^\d*\s*editor\b/.test(lowerText)) {
170
+ return 'editor';
171
+ }
168
172
  if (/^text\b/.test(lowerText) || /^\d*\s*text\b/.test(lowerText)) {
169
173
  return 'text';
170
174
  }
@@ -183,6 +187,9 @@ export class ScaffoldGenerator {
183
187
  if (/^(logo|image|img|icon)\b/.test(lowerText) || /^\d*\s*(logo|image|img|icon)\b/.test(lowerText)) {
184
188
  return 'img';
185
189
  }
190
+ if (/^(option|item|listitem)\b/.test(lowerText) || /^\d*\s*(option|item|listitem)\b/.test(lowerText)) {
191
+ return 'option';
192
+ }
186
193
  if (/^(dialog|modal)\b/.test(lowerText) || /^\d*\s*(dialog|modal)\b/.test(lowerText)) {
187
194
  return 'dialog';
188
195
  }
@@ -690,50 +697,73 @@ export class ScaffoldGenerator {
690
697
  // Only set exact when true (omit from YAML when false for cleaner output)
691
698
  const exactField = liveElement.exact ? { exact: true } : {};
692
699
 
700
+ // Strip 'name' from scaffold base — only 'role' type uses name
701
+ const { name: _scaffoldName, ...scaffoldBase } = scaffoldElement as any;
702
+
693
703
  if (selectorType === 'testid' && liveElement.testid) {
694
704
  // page.getByTestId('value')
695
705
  enriched[key] = {
696
- ...scaffoldElement,
706
+ ...scaffoldBase,
697
707
  type: 'testid' as any,
698
708
  value: liveElement.testid,
699
709
  };
700
710
  } else if (selectorType === 'role' && liveElement.role) {
701
711
  // page.getByRole('button', { name: 'Submit' })
712
+ // When name is empty (e.g. data-value fallback), keep it as '' —
713
+ // the actual name comes from test-data at runtime
702
714
  enriched[key] = {
703
- ...scaffoldElement,
715
+ ...scaffoldBase,
704
716
  type: 'role',
705
717
  value: liveElement.role,
706
- name: liveElement.name || liveElement.gherkinRef,
718
+ name: liveElement.name,
707
719
  ...exactField,
708
720
  };
709
721
  } else if (selectorType === 'label' && liveElement.label) {
710
722
  // page.getByLabel('Username')
711
723
  enriched[key] = {
712
- ...scaffoldElement,
724
+ ...scaffoldBase,
713
725
  type: 'label',
714
726
  value: liveElement.label,
715
727
  ...exactField,
716
728
  };
717
- } else if (selectorType === 'placeholder' && liveElement.placeholder) {
729
+ } else if (selectorType === 'placeholder' && (liveElement.placeholder || liveElement.selectorValue)) {
718
730
  // page.getByPlaceholder('Enter email')
719
731
  enriched[key] = {
720
- ...scaffoldElement,
732
+ ...scaffoldBase,
721
733
  type: 'placeholder',
722
- value: liveElement.placeholder,
734
+ value: liveElement.placeholder || liveElement.selectorValue,
723
735
  ...exactField,
724
736
  };
725
737
  } else if (selectorType === 'text') {
726
738
  // page.getByText('Welcome')
739
+ // When name is empty (data-value fallback), keep value empty —
740
+ // the actual text comes from test-data at runtime
727
741
  enriched[key] = {
728
- ...scaffoldElement,
742
+ ...scaffoldBase,
729
743
  type: 'text',
730
- value: liveElement.name || liveElement.gherkinRef,
744
+ value: liveElement.name,
731
745
  ...exactField,
732
746
  };
747
+ } else if (selectorType === 'locator' && liveElement.selectorValue) {
748
+ // page.locator('[contenteditable="true"]') — CSS locator
749
+ // Reset nth to 0: the original nth was for text matches, not this locator type
750
+ enriched[key] = {
751
+ ...scaffoldBase,
752
+ type: 'locator' as any,
753
+ value: liveElement.selectorValue,
754
+ nth: 0,
755
+ };
733
756
  } else {
734
757
  continue;
735
758
  }
736
759
 
760
+ // Detect contenteditable div-based editors (rich text editors like TipTap, Quill, ProseMirror)
761
+ // When the DOM element is contenteditable but NOT a native input/textarea,
762
+ // mark it so the generator uses click + pressSequentially instead of .fill()
763
+ if (liveElement.contenteditable && liveElement.tag !== 'textarea' && liveElement.tag !== 'input') {
764
+ enriched[key] = { ...enriched[key], inputMethod: 'pressSequentially' };
765
+ }
766
+
737
767
  enrichedCount++;
738
768
  if (liveElement.warning) {
739
769
  warningCount++;
@@ -0,0 +1,3 @@
1
+ const editorLocator = {{> locator}};
2
+ await editorLocator.click();
3
+ await editorLocator.pressSequentially('{{fillValue}}');
@@ -0,0 +1,2 @@
1
+ await page.waitForLoadState('networkidle');
2
+ await expect(page.getByText(/{{#if label}}{{escapeRegex label}}\s*{{else}}(?:^|\s){{/if}}{{escapeRegex dataValue}}$/)).toBeVisible();
@@ -1 +1 @@
1
- page.locator('{{escapeQuotes value}}')
1
+ {{pageRoot}}.locator('{{escapeQuotes value}}')
@@ -227,6 +227,43 @@ export const assertionPatterns: StepPattern[] = [
227
227
  !!step.dataRef &&
228
228
  step.text.includes('with'),
229
229
  generator: (step, context) => {
230
+ // Resolve data variable value
231
+ let dataValue: string;
232
+ try {
233
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
234
+ } catch (error) {
235
+ dataValue = `\${${step.dataRef}}`;
236
+ }
237
+
238
+ // Label-value pattern: "User see [X] label with {{Y}}"
239
+ // Uses regex getByText to match container with both label and value text
240
+ if (step.elementType === 'label' && step.dataRef) {
241
+ // Check if selector override has empty value — if so, omit label from regex
242
+ let label: string | undefined = step.selectorRef;
243
+ try {
244
+ const resolved = context.selectorResolver.resolveSelector(
245
+ step.selectorRef!,
246
+ context.featureName,
247
+ step.elementType,
248
+ step.nth
249
+ );
250
+ if (resolved.value !== undefined && resolved.value.trim() === '') {
251
+ label = undefined;
252
+ }
253
+ } catch {
254
+ // No selector entry — use label from selectorRef
255
+ }
256
+
257
+ const code = context.templateEngine.renderStep('label-value-assertion', {
258
+ label,
259
+ dataValue,
260
+ });
261
+ return {
262
+ code,
263
+ comment: `Assert ${step.selectorRef} label with value ${step.dataRef}`,
264
+ };
265
+ }
266
+
230
267
  // Resolve selector from YAML with feature context
231
268
  let resolved: any = {};
232
269
  try {
@@ -240,14 +277,6 @@ export const assertionPatterns: StepPattern[] = [
240
277
  // Selector not in YAML or context issue - will use variable-only
241
278
  }
242
279
 
243
- // Resolve data variable value
244
- let dataValue: string;
245
- try {
246
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
247
- } catch (error) {
248
- dataValue = `\${${step.dataRef}}`;
249
- }
250
-
251
280
  const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
252
281
 
253
282
  // Check if it's a locator strategy - use locator with filter
@@ -52,10 +52,17 @@ export const formPatterns: StepPattern[] = [
52
52
  value = step.value!;
53
53
  }
54
54
 
55
+ // Use pressSequentially template for contenteditable/rich-text editors (auto-detected by live-scan)
56
+ // or when Gherkin explicitly uses 'editor' element type
57
+ const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
58
+ const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
59
+
55
60
  return {
56
- templateName: 'fill-action',
61
+ templateName,
57
62
  data: { ...resolved, selectorRef: step.selectorRef, fillValue: value },
58
- comment: `Fill ${step.selectorRef} with ${step.dataRef || step.value}`,
63
+ comment: isEditor
64
+ ? `Fill rich text editor ${step.selectorRef} with ${step.dataRef || step.value}`
65
+ : `Fill ${step.selectorRef} with ${step.dataRef || step.value}`,
59
66
  };
60
67
  },
61
68
  priority: 10,
@@ -104,12 +104,14 @@ export class StepMapper {
104
104
  const contextVars: Record<string, any> = { inDialog: true };
105
105
 
106
106
  if (step.selectorRef && step.dataRef) {
107
- // Case 3: [panel] dialog with {{value}} → filter by resolved data value
107
+ // Case 3: [panel] dialog with {{value}}
108
+ // Use dataRef as filter text — the dialog name is dynamic (from test-data),
109
+ // so use .filter({ hasText }) instead of { name } for reliable matching
108
110
  let filterText = '';
109
111
  try {
110
112
  filterText = this.dataResolver.resolveData(step.dataRef, this.featureName);
111
113
  } catch {
112
- filterText = '';
114
+ filterText = `\${${step.dataRef}}`;
113
115
  }
114
116
  contextVars.dialogFilterText = filterText;
115
117
  } else if (step.selectorRef) {
@@ -198,6 +198,7 @@ interface SelectorEntry {
198
198
  name?: string; // Accessible name for role-based selectors (e.g., 'Login', 'Submit')
199
199
  nth?: number; // Element index (0 = no index, 1+ = append .nth())
200
200
  exact?: boolean; // Whether to use exact matching (default: false)
201
+ inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
201
202
  }
202
203
 
203
204
  // New selector file structure: flat key-value pairs
@@ -224,6 +225,7 @@ export interface ResolvedSelector {
224
225
  locator?: string; // CSS locator for fallback
225
226
  nth?: number; // Index for .nth() if multiple matches exist
226
227
  exact?: boolean; // Whether to use exact matching (default: false)
228
+ inputMethod?: string; // 'pressSequentially' for contenteditable/rich-text elements
227
229
  }
228
230
 
229
231
  /**
@@ -288,7 +290,7 @@ export class SelectorResolver {
288
290
  * NOTE: textbox/textarea must precede "text" to prevent partial matching.
289
291
  */
290
292
  static extractNthFromStep(afterElement: string): number {
291
- const nthRegex = /^\s*(?:field|button|textbox|textarea|text|link|input|element|item|logo|image|img|icon|raw|table|column|columnheader|list|listitem|row|checkbox|radio|dropdown|select|uploader|heading|header|label|caption|title|message)?\s*(\d+)\b/i;
293
+ const nthRegex = /^\s*(?:field|button|textbox|textarea|editor|text|link|input|element|item|logo|image|img|icon|raw|table|column|columnheader|list|listitem|row|checkbox|radio|dropdown|select|uploader|heading|header|label|caption|title|message)?\s*(\d+)\b/i;
292
294
  const match = nthRegex.exec(afterElement);
293
295
  return match ? parseInt(match[1], 10) : 0;
294
296
  }
@@ -391,6 +393,7 @@ export class SelectorResolver {
391
393
  'input': 'field',
392
394
  'textbox': 'field',
393
395
  'textarea': 'field',
396
+ 'editor': 'field',
394
397
  };
395
398
  return aliasMap[elementType] || elementType;
396
399
  }
@@ -518,79 +521,84 @@ export class SelectorResolver {
518
521
  const name = entry.name !== undefined && entry.name !== null ? entry.name : originalLabel;
519
522
  const nth = entry.nth || 0;
520
523
  const exact = entry.exact === true;
524
+ const inputMethod = entry.inputMethod;
525
+
526
+ // Helper to attach inputMethod only when present
527
+ const withInputMethod = (resolved: ResolvedSelector): ResolvedSelector =>
528
+ inputMethod ? { ...resolved, inputMethod } : resolved;
521
529
 
522
530
  // If type is 'locator', use locator field (or value as fallback) as the CSS locator directly
523
531
  if (type === 'locator') {
524
532
  const locatorValue = locator && locator.trim() ? locator : value;
525
- return {
533
+ return withInputMethod({
526
534
  strategy: 'locator',
527
535
  value: locatorValue,
528
536
  locator: locatorValue,
529
537
  nth,
530
- };
538
+ });
531
539
  }
532
540
 
533
541
  // If custom locator is provided and type is not 'locator', use it as CSS selector
534
542
  if (locator && locator.trim()) {
535
- return {
543
+ return withInputMethod({
536
544
  strategy: 'css',
537
545
  value: locator,
538
546
  locator: locator,
539
547
  nth,
540
- };
548
+ });
541
549
  }
542
550
 
543
551
  // Use type-based strategy
544
552
  switch (type) {
545
553
  case 'placeholder':
546
- return {
554
+ return withInputMethod({
547
555
  strategy: 'placeholder',
548
556
  value,
549
557
  nth,
550
558
  exact,
551
- };
559
+ });
552
560
 
553
561
  case 'role':
554
- return {
562
+ return withInputMethod({
555
563
  strategy: 'role',
556
564
  role: value,
557
565
  name: name, // Use the name field for accessible name
558
566
  value,
559
567
  nth,
560
568
  exact,
561
- };
569
+ });
562
570
 
563
571
  case 'testid':
564
- return {
572
+ return withInputMethod({
565
573
  strategy: 'testid',
566
574
  value,
567
575
  nth,
568
- };
576
+ });
569
577
 
570
578
  case 'label':
571
- return {
579
+ return withInputMethod({
572
580
  strategy: 'label',
573
581
  value,
574
582
  nth,
575
583
  exact,
576
- };
584
+ });
577
585
 
578
586
  case 'text':
579
- return {
587
+ return withInputMethod({
580
588
  strategy: 'text',
581
589
  value,
582
590
  nth,
583
591
  exact,
584
- };
592
+ });
585
593
 
586
594
  default:
587
595
  // Fallback to placeholder
588
- return {
596
+ return withInputMethod({
589
597
  strategy: 'placeholder',
590
598
  value,
591
599
  nth,
592
600
  exact,
593
- };
601
+ });
594
602
  }
595
603
  }
596
604