@sungen/driver-ui 3.2.24-beta.6 → 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 +2 -2
- package/src/app-profile-presets.ts +10 -0
- package/src/patterns/assertion-patterns.ts +128 -8
- package/src/patterns/form-patterns.ts +33 -1
- package/src/patterns/interaction-patterns.ts +6 -0
- package/src/patterns/navigation-patterns.ts +24 -1
- package/src/patterns/setup-patterns.ts +3 -0
- package/src/patterns/table-patterns.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
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 {
|
|
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
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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) &&
|