@sungen/driver-ui 3.2.34-beta.1 → 3.2.35

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.34-beta.1",
3
+ "version": "3.2.35",
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.34-beta.1"
16
+ "@sun-asterisk/sungen": "3.2.35"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -1,9 +1,10 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { formatToRegexLiteral } from '@sun-asterisk/sungen';
3
- import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
3
+ import { StepPattern, StepTemplateData, PatternContext, MappedStep, expandRecipe, unsupportedStepCode } from '@sun-asterisk/sungen';
4
4
  import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
5
5
  import { urlToPatternParts } from '../utils/url-path-regex';
6
6
  import { resolveColumnHeaderName } from '../utils/column-header';
7
+ import { isDatePickerType } from './form-patterns';
7
8
 
8
9
  /**
9
10
  * Assertion patterns: visibility, text content, state, attributes
@@ -44,6 +45,37 @@ const IDENTITY_STRATEGIES = new Set([
44
45
  'accessibility-id', 'android-uiautomator', 'ios-predicate',
45
46
  ]);
46
47
 
48
+ const EXPANDED_STATE_ALTERNATIVE =
49
+ 'a native app exposes no expanded state - assert what the open panel shows instead (e.g. `see [X] section`)';
50
+
51
+ /**
52
+ * `see [X] … is expanded | is collapsed` — the assertion twin of the expand/collapse actions. Before
53
+ * `expanded`/`collapsed` were state clauses the sentence fell to should-be-visible and the state
54
+ * vanished: the step passed with the calendar closed (#911). Reads the SAME signal the action drives
55
+ * (`expanded: { class | attribute }`, else aria-expanded), but strictly — the action's "assert only if
56
+ * the attribute exists" leniency would make this assert nothing. A generator, not a resolver, because
57
+ * `expanded: { state: none }` must refuse with a diagnostic rather than render a template.
58
+ */
59
+ function expandedStateAssertion(step: ParsedStep, context: PatternContext, state: 'expanded' | 'collapsed'): MappedStep {
60
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
61
+ // `state: none` is the author saying the widget shows no open/closed state, so there is nothing
62
+ // honest to assert — refuse rather than fall back to aria-expanded.
63
+ if (resolved.expanded?.state) {
64
+ const reason =
65
+ `step "${step.text}" asserts [${step.selectorRef}] is ${state}, but that entry declares `
66
+ + `expanded.state: ${resolved.expanded.state} (no observable state). Declare expanded.class or `
67
+ + `expanded.attribute on the entry, or assert what the open panel shows instead.`;
68
+ context.diagnostics?.add('SG-W017', reason, { feature: context.featureName, step: step.text });
69
+ return { code: unsupportedStepCode(reason), comment: `${step.selectorRef} has no ${state} state — see SG-W017` };
70
+ }
71
+ return {
72
+ code: context.templateEngine.renderStep('expanded-assertion', {
73
+ ...resolved, selectorRef: step.selectorRef, direction: state === 'expanded' ? 'expand' : 'collapse',
74
+ }),
75
+ comment: `Assert ${step.selectorRef} is ${state}`,
76
+ };
77
+ }
78
+
47
79
  /**
48
80
  * INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
49
81
  * MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
@@ -397,6 +429,7 @@ export const assertionPatterns: StepPattern[] = [
397
429
  ]);
398
430
  // Subset of INPUT_TYPES whose DOM implementation cannot be inferred from the token:
399
431
  // a component library may render any of these as a non-input trigger element.
432
+ // (A date-picker is handled alongside, as `isDatePicker` below, #907.)
400
433
  const AMBIGUOUS_VALUE_TYPES = new Set(['dropdown', 'select', 'combobox']);
401
434
 
402
435
  // Resolve data variable value
@@ -441,11 +474,24 @@ export const assertionPatterns: StepPattern[] = [
441
474
  (AMBIGUOUS_VALUE_TYPES.has(step.elementType) ||
442
475
  ['combobox', 'listbox'].includes(resolved.role)) &&
443
476
  resolved.variant !== 'native';
477
+ // A date-picker is just as ambiguous: its trigger is often a <button> opening a calendar
478
+ // popup, which has no `value` and shows the chosen date as text (#907).
479
+ const isDatePicker = isDatePickerType(step.elementType) && resolved.variant !== 'native';
444
480
 
445
- if (ambiguous) {
446
- // `assertValue` is the third recipe intent the app-profile spec defines alongside
447
- // `fill`/`select` (spec §3.1). Same lookup shape as the select-dropdown fork.
448
- const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
481
+ // `assertValue` is the third recipe intent the app-profile spec defines alongside
482
+ // `fill`/`select` (spec §3.1). The lookup mirrors the fill step: a per-element `variant:`
483
+ // wins for ANY element type, then the profile's widget default for the type. It used to be
484
+ // consulted for dropdowns only, so a date-picker whose `fill` ran its recipe still asserted
485
+ // with toHaveValue() on the button (#907).
486
+ // Web only: the appium adapter ships no recipe-sequence and reads native values instead.
487
+ if (context.platform !== 'mobile') {
488
+ // A date-picker reads `widgets.datepicker` first — exactly as its fill does — even when it
489
+ // wears `role: combobox` (the shadcn/Radix trigger), which also makes it `ambiguous`.
490
+ const variantName =
491
+ resolved.variant ||
492
+ (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker']
493
+ : ambiguous ? context.appProfile?.widgetVariants?.['select']
494
+ : undefined);
449
495
  const recipeSteps =
450
496
  variantName && variantName !== 'native'
451
497
  ? context.appProfile?.recipes?.[variantName]?.['assertValue']
@@ -469,7 +515,7 @@ export const assertionPatterns: StepPattern[] = [
469
515
  const code = context.templateEngine.renderStep('have-value-assertion', {
470
516
  ...resolved,
471
517
  dataValue,
472
- probeNative: ambiguous,
518
+ probeNative: ambiguous || isDatePicker,
473
519
  });
474
520
  return {
475
521
  code,
@@ -495,17 +541,27 @@ export const assertionPatterns: StepPattern[] = [
495
541
  // CONTAINS, not exact: an entry for a label/value pair points legitimately either at
496
542
  // the value node ("JD-001") or at the row holding both ("Job No: JD-001"), and the web
497
543
  // regex it replaces was itself anchored only at the end. `exact: true` opts into the
498
- // strict comparison.
544
+ // strict comparison. An EMPTY expected value is strict too: `toContainText('')` passes on
545
+ // any text at all, so "the field is blank" would assert nothing (#910). Judged on the base
546
+ // test-data value — the same compile-time read SG-W015 uses. An env overlay can change the
547
+ // value at run time; a base '' with a non-empty overlay then compares exactly.
499
548
  //
500
549
  // `resolved` is the SAME lookup, already performed above — this branch used to repeat it.
501
550
  if (IDENTITY_STRATEGIES.has(resolved.strategy)) {
551
+ // Empty on every value the ref takes (each @cases row, #950) → strict.
552
+ const values = context.dataResolver.peekValues
553
+ ? context.dataResolver.peekValues(step.dataRef, context.featureName)
554
+ : [context.dataResolver.peekBaseValue?.(step.dataRef, context.featureName)].filter((v): v is string => v !== undefined);
555
+ const strict = resolved.exact || (!!values?.length && values.every((v) => v === ''));
502
556
  const code = context.templateEngine.renderStep(
503
- resolved.exact ? 'have-text-assertion' : 'contain-text-assertion',
504
- { ...resolved, expectedText: dataValue },
557
+ strict ? 'have-text-assertion' : 'contain-text-assertion',
558
+ // `strict` reads the BASE value at compile time; the runtime guard also covers an env
559
+ // overlay that sets '' over a non-empty base (#910).
560
+ { ...resolved, expectedText: dataValue, emptyMeansEmpty: true },
505
561
  );
506
562
  return {
507
563
  code,
508
- comment: `Assert ${step.selectorRef} label ${resolved.exact ? 'has' : 'contains'} value ${step.dataRef}`,
564
+ comment: `Assert ${step.selectorRef} label ${strict ? 'has' : 'contains'} value ${step.dataRef}`,
509
565
  };
510
566
  }
511
567
 
@@ -827,6 +883,28 @@ export const assertionPatterns: StepPattern[] = [
827
883
  },
828
884
  priority: 11,
829
885
  },
886
+ {
887
+ name: 'is-expanded',
888
+ states: ['expanded'],
889
+ platforms: ['web'],
890
+ platformAlternative: EXPANDED_STATE_ALTERNATIVE,
891
+ // A word match like the sibling state patterns (`is-disabled`), not `step.state`: that one is set
892
+ // only for a TRAILING clause, so `… is expanded # calendar open` fell back to toBeVisible().
893
+ matcher: (step: ParsedStep) =>
894
+ /\b(see|sees)\s+\[/.test(step.text) && /\bis expanded\b/.test(step.text) && !step.dataRef && !!step.selectorRef,
895
+ generator: (step, context) => expandedStateAssertion(step, context, 'expanded'),
896
+ priority: 11,
897
+ },
898
+ {
899
+ name: 'is-collapsed',
900
+ states: ['collapsed'],
901
+ platforms: ['web'],
902
+ platformAlternative: EXPANDED_STATE_ALTERNATIVE,
903
+ matcher: (step: ParsedStep) =>
904
+ /\b(see|sees)\s+\[/.test(step.text) && /\bis collapsed\b/.test(step.text) && !step.dataRef && !!step.selectorRef,
905
+ generator: (step, context) => expandedStateAssertion(step, context, 'collapsed'),
906
+ priority: 11,
907
+ },
830
908
  {
831
909
  name: 'is-focused',
832
910
  states: ['focused'],
@@ -1,6 +1,25 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
3
3
 
4
+ /** A date-picker / calendar element type — the steps that read `widgets.datepicker` agree on it. */
5
+ export const isDatePickerType = (elementType: string | undefined): boolean =>
6
+ /date-?picker|calendar/.test(String(elementType || ''));
7
+
8
+ /**
9
+ * Whether an entry provably locates a `<button>`: a `button` role, or a CSS/locator selector whose
10
+ * LAST compound starts with the `button` tag (`div.filters button[aria-haspopup="dialog"]`).
11
+ * Attribute brackets are dropped first so a `[name="button"]` value cannot read as the tag. A
12
+ * selector LIST (`input.date, button.trigger`) may match the input, so it is never called a button.
13
+ */
14
+ function isButtonTrigger(resolved: { strategy?: string; role?: string; value?: string }): boolean {
15
+ if (resolved.strategy === 'role') return resolved.role === 'button';
16
+ if (resolved.strategy !== 'css' && resolved.strategy !== 'locator') return false;
17
+ const selector = String(resolved.value ?? '').replace(/\[[^\]]*\]/g, '').trim();
18
+ if (selector.includes(',')) return false;
19
+ const compounds = selector.split(/[\s>+~]+/);
20
+ return /^button(?![\w-])/i.test(compounds[compounds.length - 1] ?? '');
21
+ }
22
+
4
23
  /**
5
24
  * Form action patterns: fill, type, select, check, uncheck
6
25
  */
@@ -50,7 +69,7 @@ export const formPatterns: StepPattern[] = [
50
69
  // Widget recipe hook: per-element `variant:` wins; else, for a date-picker element,
51
70
  // the profile's `widgets.datepicker` (custom datepickers — readonly inputs that need
52
71
  // open-panel choreography). Resolves to a recipe with a `fill` intent.
53
- const isDatePicker = /date-?picker|calendar/.test(String(step.elementType || ''));
72
+ const isDatePicker = isDatePickerType(step.elementType);
54
73
  const fillVariant = resolved.variant || (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker'] : undefined);
55
74
  const fillRecipe = fillVariant ? context.appProfile?.recipes?.[fillVariant]?.['fill'] : undefined;
56
75
  if (fillRecipe) {
@@ -92,6 +111,25 @@ export const formPatterns: StepPattern[] = [
92
111
  };
93
112
  }
94
113
 
114
+ // A web date-picker whose trigger is a BUTTON, with no recipe: `.fill()` throws "Element is
115
+ // not an <input>" on it, and there is no value to type — the date is chosen in the calendar
116
+ // the button opens. Refuse at compile time and name the recipe to declare (#907). Only the
117
+ // shapes that are provably a button are refused; any other locator may still be a native
118
+ // `<input type="date">`, where the generic fill is right.
119
+ if (isDatePicker && isButtonTrigger(resolved)) {
120
+ const reason =
121
+ `step "${step.text}" fills [${step.selectorRef}] date-picker, but that entry locates a button `
122
+ + `(a calendar trigger) and no recipe drives it — fill() cannot type into a button. Declare a `
123
+ + `recipe with a fill intent in qa/app.yaml and point the entry at it with variant: <recipe> `
124
+ + `(or set widgets.datepicker), or tag this scenario @manual.`;
125
+ context.diagnostics?.add('SG-W017', reason, { feature: context.featureName, step: step.text });
126
+ return {
127
+ templateName: 'date-picker-unsupported',
128
+ data: { reason },
129
+ comment: `date-picker trigger is a button with no fill recipe — see SG-W017`,
130
+ };
131
+ }
132
+
95
133
  // Rich-text editors fill with pressSequentially, not .fill(). Two ways in: the selector
96
134
  // entry (`contenteditable: true`, which the resolver translates into `inputMethod`, #712 —
97
135
  // or `inputMethod` written out), and the Gherkin `editor` element type, which is a property
@@ -104,9 +142,16 @@ export const formPatterns: StepPattern[] = [
104
142
  const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
105
143
  const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
106
144
 
145
+ // `.fill()` only works on an input / textarea / select / contenteditable. A date-picker whose
146
+ // trigger is a <button> opening a calendar popup (Radix Popover + React Day Picker), or an
147
+ // entry that is plainly a button or link, fails with Playwright's "Element is not an <input>"
148
+ // — which says nothing about the fix. Guard those at runtime with the way forward: a recipe
149
+ // with a `fill` intent (#907). A locator's tag is unknown at compile time, hence a probe.
150
+ const guardFillable = !isEditor && (isDatePicker || ['button', 'link'].includes(String(resolved.role || '')));
151
+
107
152
  return {
108
153
  templateName,
109
- data: { ...resolved, selectorRef: step.selectorRef, fillValue: value },
154
+ data: { ...resolved, selectorRef: step.selectorRef, fillValue: value, ...(guardFillable ? { guardFillable: true } : {}) },
110
155
  comment: isEditor
111
156
  ? `Fill rich text editor ${step.selectorRef} with ${step.dataRef || step.value}`
112
157
  : `Fill ${step.selectorRef} with ${step.dataRef || step.value}`,
@@ -234,9 +234,21 @@ export const tablePatterns: StepPattern[] = [
234
234
  !/\bis\s+hidden\b/i.test(step.text);
235
235
  },
236
236
  resolver: (step, context) => {
237
- // Decide by the BASE literal (runtime mode returns an opaque marker from resolveData).
238
- const base = context.dataResolver.peekBaseValue?.(step.dataRef!, context.featureName);
239
- if (base === undefined || !/^\d+$/.test(String(base).trim())) return null as any; // not numeric → fall through to text semantics
237
+ // Decide by the literal value(s) (runtime mode returns an opaque marker from resolveData). In
238
+ // @cases a dataset column has one value per row: it is a count only when EVERY row is a
239
+ // number (#950). Mixed rows compile to text — and say so, since some rows read as counts.
240
+ const values = context.dataResolver.peekValues
241
+ ? context.dataResolver.peekValues(step.dataRef!, context.featureName)
242
+ : [context.dataResolver.peekBaseValue?.(step.dataRef!, context.featureName)].filter((v): v is string => v !== undefined);
243
+ const isCount = (v: string) => /^\d+$/.test(v.trim());
244
+ if (!values?.length || !values.every(isCount)) {
245
+ if (values?.some(isCount)) {
246
+ context.diagnostics?.add('SG-W015',
247
+ `\`see [${step.selectorRef}] row with {{${step.dataRef}}}\` mixes numbers and text across the @cases rows, so it compiles to a TEXT assertion, not a row count. Split the counting rows into their own @cases scenario.`,
248
+ { feature: context.featureName, step: step.text });
249
+ }
250
+ return null as any; // not numeric → fall through to text semantics
251
+ }
240
252
  const resolved = context.selectorResolver.resolveSelector(
241
253
  step.selectorRef!, context.featureName, 'row', step.nth
242
254
  );