@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.
|
|
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.
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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 ${
|
|
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 =
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
);
|