@sungen/driver-ui 3.2.24 → 3.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-ui",
3
- "version": "3.2.24",
3
+ "version": "3.2.25",
4
4
  "description": "Sungen UI capability (Playwright) — step patterns, viewpoint gate, and phase hooks. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.24"
16
+ "@sun-asterisk/sungen": "3.2.25"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -20,6 +20,11 @@ export const APP_PROFILE_PRESETS: Record<string, unknown> = {
20
20
  { click: '$trigger' },
21
21
  { clickText: { role: 'option', text: '$value', exact: true } },
22
22
  ],
23
+ // The trigger is a <button role="combobox"> — it has no DOM `value`, so the selected
24
+ // option is read from its text (Radix renders it into <SelectValue> inside the button).
25
+ assertValue: [
26
+ { expectText: { locator: '$trigger', value: '$value', exact: true } },
27
+ ],
23
28
  },
24
29
  // Ant Design Select: closed panels stay in the DOM hidden — scope every lookup
25
30
  // to the VISIBLE dropdown panel. `waitFor: attached` first because a select that
@@ -33,6 +38,11 @@ export const APP_PROFILE_PRESETS: Record<string, unknown> = {
33
38
  { waitFor: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)' },
34
39
  { clickText: { within: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)', text: '$value' } },
35
40
  ],
41
+ // AntD keeps the chosen label in `.ant-select-selection-item` inside the trigger, not in
42
+ // a form value — assert the trigger's text, never toHaveValue().
43
+ assertValue: [
44
+ { expectText: { locator: '$trigger', value: '$value', exact: true } },
45
+ ],
36
46
  },
37
47
  // Ant Design DatePicker (typed input + panel): after typing, the panel keeps
38
48
  // blocking pointer events; a single blur dismisses it (Escape/force-click are
@@ -1,5 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
- import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
2
+ import { formatToRegexLiteral } from '@sun-asterisk/sungen';
3
+ import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
3
4
  import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
4
5
  import { pathToRegexSource } from '../utils/url-path-regex';
5
6
 
@@ -23,6 +24,22 @@ function qualifiedState(text: string): { assertion: string; label: string; probe
23
24
  return null;
24
25
  }
25
26
 
27
+ /** Locator-strategy element-type tokens that name a LIST MEMBER (many-matching by design —
28
+ * custom widgets with no role, e.g. a 96-slot time picker). Text assertions on these must
29
+ * filter the multi-locator by the value first: a bare toHaveText/toContainText on the raw
30
+ * locator strict-mode-fails. The role-strategy counterpart is LIST_MEMBER_ROLES. */
31
+ const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
32
+
33
+ /**
34
+ * INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
35
+ * MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
36
+ * `throw`; over-declared, the clause is silently dropped and the assertion reads as something it is
37
+ * not. Determine the truth from the TEMPLATE the resolver names, never from the matcher.
38
+ *
39
+ * Deliberately NOT declared, because each really does drop the clause today: `page-assertion`
40
+ * (a page has no state — this was the original defect), `wait-for-page`, `table-row-exists`,
41
+ * `table-column-exists`, `see-with-variable`, `alert-text-assertion`, `all-contain-element`.
42
+ */
26
43
  export const assertionPatterns: StepPattern[] = [
27
44
  // Browser alert text assertion: "see [Are you sure?] alert"
28
45
  {
@@ -145,6 +162,7 @@ export const assertionPatterns: StepPattern[] = [
145
162
  // Pattern: Then User see [panel] dialog with {{kudo.title}} hidden -> toBeHidden()
146
163
  {
147
164
  name: 'see-with-variable-hidden',
165
+ states: ['hidden'],
148
166
  matcher: (step: ParsedStep) =>
149
167
  (step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
150
168
  !!step.selectorRef &&
@@ -217,6 +235,7 @@ export const assertionPatterns: StepPattern[] = [
217
235
  // Pattern: Then User see [submit] button with {{label}} disabled -> toBeDisabled()
218
236
  {
219
237
  name: 'see-with-variable-disabled',
238
+ states: ['disabled'],
220
239
  matcher: (step: ParsedStep) =>
221
240
  (step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
222
241
  !!step.selectorRef &&
@@ -287,6 +306,9 @@ export const assertionPatterns: StepPattern[] = [
287
306
  // `is disabled` / `is hidden` keep their existing dedicated twins — not matched here.
288
307
  {
289
308
  name: 'see-with-variable-state',
309
+ // Exactly the labels `qualifiedState()` above resolves — `hidden`/`disabled` are excluded
310
+ // there and owned by the -hidden/-disabled twins.
311
+ states: ['checked', 'empty', 'enabled', 'focused', 'selected', 'unchecked'],
290
312
  matcher: (step: ParsedStep) =>
291
313
  (step.text.includes('should see') || !!step.text.match(/\b(see|sees)\s+\[/)) &&
292
314
  !!step.selectorRef &&
@@ -351,6 +373,9 @@ export const assertionPatterns: StepPattern[] = [
351
373
  'field', 'textarea', 'search', 'dropdown', 'slider', 'date-picker',
352
374
  'input', 'textbox', 'editor', 'select', 'combobox',
353
375
  ]);
376
+ // Subset of INPUT_TYPES whose DOM implementation cannot be inferred from the token:
377
+ // a component library may render any of these as a non-input trigger element.
378
+ const AMBIGUOUS_VALUE_TYPES = new Set(['dropdown', 'select', 'combobox']);
354
379
 
355
380
  // Resolve data variable value
356
381
  let dataValue: string;
@@ -387,10 +412,46 @@ export const assertionPatterns: StepPattern[] = [
387
412
  }
388
413
 
389
414
  // --- Input types: toHaveValue ---
415
+ // `dropdown`/`select`/`combobox` are AMBIGUOUS: ARIA role `combobox` is worn both by a
416
+ // native <select> and by a `<button role="combobox">` trigger (Radix/shadcn, MUI,
417
+ // Headless UI). toHaveValue() reads the DOM `value` property, which a button has not got
418
+ // — hence the runtime "Not an input element". Resolution follows the app-profile layering
419
+ // (sungen_app_profile_spec.md §3): per-element `variant:` / app-profile recipe wins, and
420
+ // the built-in default probes the tag at runtime instead of assuming a native input.
390
421
  if (step.elementType && INPUT_TYPES.has(step.elementType)) {
422
+ const ambiguous =
423
+ (AMBIGUOUS_VALUE_TYPES.has(step.elementType) ||
424
+ ['combobox', 'listbox'].includes(resolved.role)) &&
425
+ resolved.variant !== 'native';
426
+
427
+ if (ambiguous) {
428
+ // `assertValue` is the third recipe intent the app-profile spec defines alongside
429
+ // `fill`/`select` (spec §3.1). Same lookup shape as the select-dropdown fork.
430
+ const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
431
+ const recipeSteps =
432
+ variantName && variantName !== 'native'
433
+ ? context.appProfile?.recipes?.[variantName]?.['assertValue']
434
+ : undefined;
435
+ if (recipeSteps) {
436
+ try {
437
+ const steps = expandRecipe(recipeSteps as unknown[], {
438
+ triggerCode: context.renderLocator(resolved),
439
+ value: dataValue,
440
+ });
441
+ return {
442
+ code: context.templateEngine.renderStep('recipe-sequence', { steps }),
443
+ comment: `Assert ${step.selectorRef} has value ${step.dataRef} (${variantName} recipe)`,
444
+ };
445
+ } catch {
446
+ // malformed recipe -> fall through to the runtime probe below
447
+ }
448
+ }
449
+ }
450
+
391
451
  const code = context.templateEngine.renderStep('have-value-assertion', {
392
452
  ...resolved,
393
453
  dataValue,
454
+ probeNative: ambiguous,
394
455
  });
395
456
  return {
396
457
  code,
@@ -453,17 +514,18 @@ export const assertionPatterns: StepPattern[] = [
453
514
 
454
515
  // --- Everything else: toHaveText (exact full match) ---
455
516
  // Locator-strategy LIST MEMBERS first: `see [X] item|option|cell with {{v}}` on a
456
- // CSS-locator list (custom widgets with no role — e.g. a 96-slot time picker) means
457
- // "a member whose text is v exists". A bare toHaveText on the multi-matching locator
458
- // strict-mode-fails, so filter by the EXACT value then assert visible — the locator
459
- // counterpart of the role-based branch below. Unique-element types (text/message/…)
517
+ // CSS-locator list means "a member whose text is v exists". A bare toHaveText on the
518
+ // multi-matching locator strict-mode-fails, so filter by the value then assert
519
+ // visible — the locator counterpart of the role-based branch below. The filter
520
+ // matches by SUBSTRING (Playwright's standard hasText) unless the selector YAML
521
+ // declares `exact: true` — the same contract as the role branch; a hardcoded exact
522
+ // here rejected members whose rendered text wraps the value (icons, counts, i18n
523
+ // suffixes) with no way to opt out. Unique-element types (text/message/…)
460
524
  // keep toHaveText: that is a VALUE assertion and must not be weakened to existence.
461
- const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
462
525
  if (resolved.strategy === 'locator' && step.elementType && LOCATOR_LIST_MEMBER_TYPES.has(step.elementType)) {
463
526
  const code = context.templateEngine.renderStep('visible-filtered-assertion', {
464
527
  ...resolved,
465
528
  dataValue,
466
- exact: true,
467
529
  });
468
530
  return {
469
531
  code,
@@ -534,6 +596,9 @@ export const assertionPatterns: StepPattern[] = [
534
596
  },
535
597
  {
536
598
  name: 'should-be-visible',
599
+ // `is visible` is redundant (bare `see [X] type` already asserts it) but harmless — the review
600
+ // checklist flags the redundancy; the compiler must not turn it into a hard failure.
601
+ states: ['visible'],
537
602
  matcher: (step: ParsedStep) =>
538
603
  (step.text.includes('should be visible') ||
539
604
  step.text.includes('should be displayed') ||
@@ -552,6 +617,7 @@ export const assertionPatterns: StepPattern[] = [
552
617
  },
553
618
  {
554
619
  name: 'is-hidden',
620
+ states: ['hidden'],
555
621
  // "see [target] is hidden" — no variable version; with-variable handled by see-with-variable-hidden
556
622
  matcher: (step: ParsedStep) =>
557
623
  /\b(see|sees)\s+\[/.test(step.text) && /\bis hidden\b/.test(step.text) &&
@@ -568,6 +634,7 @@ export const assertionPatterns: StepPattern[] = [
568
634
  },
569
635
  {
570
636
  name: 'should-be-enabled',
637
+ states: ['enabled'],
571
638
  matcher: (step: ParsedStep) =>
572
639
  /\b(see|sees)\s+\[/.test(step.text) && /\bis enabled\b/.test(step.text) && !!step.selectorRef,
573
640
  resolver: (step, context): StepTemplateData => {
@@ -582,6 +649,7 @@ export const assertionPatterns: StepPattern[] = [
582
649
  },
583
650
  {
584
651
  name: 'should-be-disabled',
652
+ states: ['disabled'],
585
653
  matcher: (step: ParsedStep) =>
586
654
  /\b(see|sees)\s+\[/.test(step.text) && /\bis disabled\b/.test(step.text) && !!step.selectorRef,
587
655
  resolver: (step, context): StepTemplateData => {
@@ -594,6 +662,29 @@ export const assertionPatterns: StepPattern[] = [
594
662
  },
595
663
  priority: 11,
596
664
  },
665
+ {
666
+ // `matches "<format>"` — assert the SHAPE of a label, not its value. A spec states a label's
667
+ // contract as "Format: yyyy/mm/dd ~ yyyy/mm/dd" or "Format: 残りX個"; neither existing text
668
+ // assertion can carry that (an exact value pins data that changes daily, `contains` passes on
669
+ // anything). Requires the quoted form so it cannot claim the API-side `matches schema [Ref]`.
670
+ // Priority above should-have-text / should-contain-text (both 12): those match on the words
671
+ // "has text" / "contains", which a format step does not use, but the generic visibility
672
+ // catch-all would otherwise take an unrecognised `see [X] label matches …`.
673
+ name: 'matches-format',
674
+ matcher: (step: ParsedStep) =>
675
+ /\bmatches\s+"/i.test(step.text) && !!step.selectorRef && !!step.value,
676
+ resolver: (step, context): StepTemplateData => {
677
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
678
+ return {
679
+ templateName: 'matches-format-assertion',
680
+ // `format` rides along so a failure message can name the shape the author asked for —
681
+ // a bare regex in the error is the hardest kind of red for a QA to read.
682
+ data: { ...resolved, selectorRef: step.selectorRef, formatRegex: formatToRegexLiteral(step.value!), format: step.value },
683
+ comment: `Assert ${step.selectorRef} matches the format "${step.value}"`,
684
+ };
685
+ },
686
+ priority: 16,
687
+ },
597
688
  {
598
689
  name: 'should-contain-text',
599
690
  matcher: (step: ParsedStep) =>
@@ -601,10 +692,31 @@ export const assertionPatterns: StepPattern[] = [
601
692
  resolver: (step, context): StepTemplateData => {
602
693
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
603
694
  const expectedText = step.value || context.dataResolver.resolveData(step.dataRef!, context.featureName);
695
+ const comment = `Assert ${step.selectorRef} contains "${step.value || step.dataRef}"`;
696
+ // LIST MEMBERS (locator item/option/cell types, role listitem/option/cell): the
697
+ // locator matches many elements by design, so a bare toContainText strict-mode-fails.
698
+ // `contains` on a list means "a member containing v exists" — filter by the substring
699
+ // then assert visible, the same routing the with-{{v}} existence branches use.
700
+ // Always substring here (never the YAML `exact`): the step itself says "contains".
701
+ // Unique elements keep toContainText below — a VALUE assertion is not weakened.
702
+ // A role entry pinned to ONE element (explicit `name:` or `nth:`) counts as unique:
703
+ // filter-then-visible would turn its value check into a visibility gate (a collapsed
704
+ // but present member would flip from pass to fail with a worse failure message).
705
+ const isListMember =
706
+ (resolved.strategy === 'locator' && step.elementType && LOCATOR_LIST_MEMBER_TYPES.has(step.elementType)) ||
707
+ (resolved.strategy === 'role' && !!resolved.role && LIST_MEMBER_ROLES.has(resolved.role) &&
708
+ !(resolved.name && resolved.name.trim()) && resolved.nth == null);
709
+ if (isListMember) {
710
+ return {
711
+ templateName: 'visible-filtered-assertion',
712
+ data: { ...resolved, exact: false, dataValue: expectedText },
713
+ comment,
714
+ };
715
+ }
604
716
  return {
605
717
  templateName: 'contain-text-assertion',
606
718
  data: { ...resolved, expectedText },
607
- comment: `Assert ${step.selectorRef} contains "${step.value || step.dataRef}"`,
719
+ comment,
608
720
  };
609
721
  },
610
722
  priority: 12,
@@ -626,6 +738,7 @@ export const assertionPatterns: StepPattern[] = [
626
738
  },
627
739
  {
628
740
  name: 'is-empty',
741
+ states: ['empty'],
629
742
  matcher: (step: ParsedStep) =>
630
743
  /\b(see|sees)\s+\[/.test(step.text) && /\bis empty\b/.test(step.text) && !!step.selectorRef,
631
744
  resolver: (step, context): StepTemplateData => {
@@ -640,6 +753,7 @@ export const assertionPatterns: StepPattern[] = [
640
753
  },
641
754
  {
642
755
  name: 'is-checked',
756
+ states: ['checked'],
643
757
  matcher: (step: ParsedStep) =>
644
758
  /\b(see|sees)\s+\[/.test(step.text) && /\bis checked\b/.test(step.text) && !!step.selectorRef,
645
759
  resolver: (step, context): StepTemplateData => {
@@ -654,6 +768,7 @@ export const assertionPatterns: StepPattern[] = [
654
768
  },
655
769
  {
656
770
  name: 'is-unchecked',
771
+ states: ['unchecked'],
657
772
  matcher: (step: ParsedStep) =>
658
773
  /\b(see|sees)\s+\[/.test(step.text) && /\bis unchecked\b/.test(step.text) && !!step.selectorRef,
659
774
  resolver: (step, context): StepTemplateData => {
@@ -668,6 +783,7 @@ export const assertionPatterns: StepPattern[] = [
668
783
  },
669
784
  {
670
785
  name: 'is-focused',
786
+ states: ['focused'],
671
787
  matcher: (step: ParsedStep) =>
672
788
  /\b(see|sees)\s+\[/.test(step.text) && /\bis focused\b/.test(step.text) && !!step.selectorRef,
673
789
  resolver: (step, context): StepTemplateData => {
@@ -771,6 +887,7 @@ export const assertionPatterns: StepPattern[] = [
771
887
  },
772
888
  {
773
889
  name: 'is-loading',
890
+ states: ['loading'],
774
891
  matcher: (step: ParsedStep) =>
775
892
  /\b(see|sees)\s+\[/.test(step.text) && /\bis loading\b/.test(step.text) && !!step.selectorRef,
776
893
  resolver: (step, context): StepTemplateData => {
@@ -785,6 +902,7 @@ export const assertionPatterns: StepPattern[] = [
785
902
  },
786
903
  {
787
904
  name: 'is-selected',
905
+ states: ['selected'],
788
906
  matcher: (step: ParsedStep) =>
789
907
  /\b(see|sees)\s+\[/.test(step.text) && /\bis selected\b/.test(step.text) && !!step.selectorRef,
790
908
  resolver: (step, context): StepTemplateData => {
@@ -799,6 +917,7 @@ export const assertionPatterns: StepPattern[] = [
799
917
  },
800
918
  {
801
919
  name: 'is-sorted-ascending',
920
+ states: ['sorted ascending'],
802
921
  matcher: (step: ParsedStep) =>
803
922
  /\b(see|sees)\s+\[/.test(step.text) && /\bsorted ascending\b/.test(step.text) && !!step.selectorRef,
804
923
  resolver: (step, context): StepTemplateData => {
@@ -813,6 +932,7 @@ export const assertionPatterns: StepPattern[] = [
813
932
  },
814
933
  {
815
934
  name: 'is-sorted-descending',
935
+ states: ['sorted descending'],
816
936
  matcher: (step: ParsedStep) =>
817
937
  /\b(see|sees)\s+\[/.test(step.text) && /\bsorted descending\b/.test(step.text) && !!step.selectorRef,
818
938
  resolver: (step, context): StepTemplateData => {
@@ -74,6 +74,29 @@ export const formPatterns: StepPattern[] = [
74
74
  }
75
75
  }
76
76
 
77
+ // A date-picker on MOBILE with no recipe configured: refuse rather than type into it.
78
+ // The generic fill is `click → clearValue → setValue`, which is right for a text field and
79
+ // wrong for a native picker — Android opens a spinner/calendar DIALOG, so setValue lands on
80
+ // the trigger, selects nothing, and reports success. The step then reads as "the date was
81
+ // chosen" while nothing was. The recipe hook above is the supported answer (an app profile
82
+ // declaring `widgets.datepicker`); without one there is no honest compilation, so this
83
+ // follows the upload-step precedent and fails loud with the two ways forward.
84
+ if (isDatePicker && context.platform === 'mobile') {
85
+ return {
86
+ templateName: 'date-picker-unsupported',
87
+ data: {
88
+ reason:
89
+ // No double quotes in this text: it is rendered INTO a double-quoted JS string literal,
90
+ // where a stray quote would break the generated file rather than the step.
91
+ `date-picker step for [${step.selectorRef}] cannot be compiled on the appium adapter: `
92
+ + `a native picker is a dialog, so the generic fill would type into the trigger and select `
93
+ + `nothing while reporting success. Declare the picker choreography in qa/app.yaml `
94
+ + `(widgets.datepicker, a recipe with a fill intent), or tag this scenario @manual.`,
95
+ },
96
+ comment: `date-picker not compilable on mobile without a recipe — see qa/app.yaml widgets.datepicker`,
97
+ };
98
+ }
99
+
77
100
  // Use pressSequentially template for contenteditable/rich-text editors (auto-detected by live-scan)
78
101
  // or when Gherkin explicitly uses 'editor' element type
79
102
  const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
@@ -180,7 +203,16 @@ export const formPatterns: StepPattern[] = [
180
203
 
181
204
  if (isNativeSelect) {
182
205
  templateName = 'select-action';
183
- data = { ...resolved, selectorRef: step.selectorRef, selectValue: value };
206
+ // Role alone cannot prove a native <select>: `combobox` is also the role of a
207
+ // `<button role="combobox">` trigger (Radix/shadcn, MUI, Headless UI), where
208
+ // .selectOption() throws "Element is not a <select> element". Probe the tag at
209
+ // runtime unless the entry pins `variant: native`. A recipe (above) still wins.
210
+ data = {
211
+ ...resolved,
212
+ selectorRef: step.selectorRef,
213
+ selectValue: value,
214
+ probeNative: resolved.variant !== 'native',
215
+ };
184
216
  } else {
185
217
  templateName = 'click-select-action';
186
218
  data = { ...resolved, selectorRef: step.selectorRef, selectValue: value };
@@ -384,6 +384,9 @@ export const interactionPatterns: StepPattern[] = [
384
384
  },
385
385
  {
386
386
  name: 'wait-for-role-with-text',
387
+ // Shares `extractWaitState()` with wait-for-element — same four, same reason for the
388
+ // omissions.
389
+ states: ['hidden', 'disabled', 'enabled', 'visible'],
387
390
  matcher: (step: ParsedStep) =>
388
391
  (step.text.includes('wait for') || step.text.includes('waits for')) &&
389
392
  !step.selectorRef &&
@@ -515,6 +518,9 @@ export const interactionPatterns: StepPattern[] = [
515
518
  },
516
519
  {
517
520
  name: 'wait-for-element',
521
+ // Exactly what `extractWaitState()` resolves. Every other clause falls back to 'visible', i.e.
522
+ // is silently dropped — so it is deliberately NOT declared and now reports SG-E021.
523
+ states: ['hidden', 'disabled', 'enabled', 'visible'],
518
524
  matcher: (step: ParsedStep) =>
519
525
  (step.text.includes('wait for') || step.text.includes('waits for')) && !!step.selectorRef,
520
526
  resolver: (step, context) => {
@@ -3,6 +3,29 @@ import { StepPattern } from '@sun-asterisk/sungen';
3
3
  import { inferPath, resolvePathVariables, getPathCode } from '@sun-asterisk/sungen';
4
4
  import { pathToRegexSource } from '../utils/url-path-regex';
5
5
 
6
+ /**
7
+ * `is on` names a NAVIGATION only for a page — or for an untyped / data-bound reference, the
8
+ * legacy `is on {{route}}` form kept working here. `is on [X] <any other type>` means "the user
9
+ * is AT X", a state inside the current page, and must not reach this pattern: its Given/When
10
+ * branch emits page.goto(<the SCREEN's path>, ignoring [X]), which on a multi-step flow is a
11
+ * full reload that wipes the state the earlier steps built, and its Then branch asserts that
12
+ * same path, which on a single-URL SPA can never fail. Both were silent. The step-mapper now
13
+ * refuses those shapes outright; this guard keeps the pattern from claiming them meanwhile.
14
+ */
15
+ const isOnNavigation = (step: ParsedStep): boolean => {
16
+ const verb = /\bis\s+on\b/i.exec(step.text);
17
+ if (!verb) return false;
18
+ // `see [Notification Toggle] is on` — the reference precedes the words, so they are a STATE
19
+ // being asserted, not the navigation verb. Claiming it here compiled a toggle assertion into
20
+ // a URL assertion that, on the screen's own path, could never fail.
21
+ const bracket = step.text.indexOf('[');
22
+ if (bracket !== -1 && bracket < verb.index) return false;
23
+ if (!/\bis\s+on\s+\[/i.test(step.text)) return true; // no typed reference — legacy route form
24
+ // `screen` is the mobile analog of `page` (the appium adapter renders it as the app-launch
25
+ // no-op); keep it navigating so a dual-target feature compiles the same on both.
26
+ return !step.elementType || step.elementType === 'page' || step.elementType === 'screen';
27
+ };
28
+
6
29
  /**
7
30
  * Navigation patterns: goto, navigate, open page
8
31
  */
@@ -115,7 +138,7 @@ export const navigationPatterns: StepPattern[] = [
115
138
  {
116
139
  name: 'navigate-to-route',
117
140
  matcher: (step: ParsedStep) =>
118
- (step.text.includes('navigate to') || step.text.includes('navigates to') || step.text.includes('is on')) &&
141
+ (step.text.includes('navigate to') || step.text.includes('navigates to') || isOnNavigation(step)) &&
119
142
  !!(step.selectorRef || step.dataRef),
120
143
  resolver: (step, context) => {
121
144
  const route = step.selectorRef || step.dataRef;
@@ -49,6 +49,9 @@ export const setupPatterns: StepPattern[] = [
49
49
  },
50
50
  {
51
51
  name: 'clear-database',
52
+ // `Given database is empty` ESTABLISHES the state rather than asserting it — the clause is the
53
+ // directive, not a dropped assertion.
54
+ states: ['empty'],
52
55
  matcher: (step: ParsedStep) =>
53
56
  step.text.includes('database is empty') ||
54
57
  step.text.includes('database is cleared') ||
@@ -95,6 +95,7 @@ export const tablePatterns: StepPattern[] = [
95
95
  // "User see [Table] table is empty"
96
96
  {
97
97
  name: 'table-is-empty',
98
+ states: ['empty'],
98
99
  matcher: (step) => {
99
100
  return /\btable\s+is\s+empty\b/i.test(step.text);
100
101
  },
@@ -207,6 +208,7 @@ export const tablePatterns: StepPattern[] = [
207
208
  // "User see [Ref] row in [Table] table with {{value}} is hidden"
208
209
  {
209
210
  name: 'table-row-hidden',
211
+ states: ['hidden'],
210
212
  matcher: (step) => {
211
213
  return step.elementType === 'row' &&
212
214
  /\bin\b.*\btable\b/i.test(step.text) &&