@sungen/driver-ui 3.2.28 → 3.2.30
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/index.ts +2 -0
- package/src/patterns/assertion-patterns.ts +71 -14
- package/src/patterns/capture-patterns.ts +3 -0
- package/src/patterns/focus-patterns.ts +3 -0
- package/src/patterns/form-patterns.ts +22 -4
- package/src/patterns/interaction-patterns.ts +116 -5
- package/src/patterns/navigation-patterns.ts +3 -0
- package/src/patterns/storage-patterns.ts +6 -0
- package/src/patterns/table-pagination-patterns.ts +102 -0
- package/src/patterns/table-patterns.ts +138 -23
- package/src/utils/column-header.ts +54 -0
- package/src/utils/row-index.ts +8 -2
- package/src/utils/url-path-regex.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.30",
|
|
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.30"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@playwright/test": "^1.57.0"
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { keyboardPatterns } from './patterns/keyboard-patterns';
|
|
|
36
36
|
import { scrollPatterns } from './patterns/scroll-patterns';
|
|
37
37
|
import { scopePatterns } from './patterns/scope-patterns';
|
|
38
38
|
import { tablePatterns } from './patterns/table-patterns';
|
|
39
|
+
import { tablePaginationPatterns } from './patterns/table-pagination-patterns';
|
|
39
40
|
import { capturePatterns } from './patterns/capture-patterns';
|
|
40
41
|
import { storagePatterns } from './patterns/storage-patterns';
|
|
41
42
|
import { focusPatterns } from './patterns/focus-patterns';
|
|
@@ -109,6 +110,7 @@ export function register(registry: CapabilityRegistry): void {
|
|
|
109
110
|
...scrollPatterns,
|
|
110
111
|
...scopePatterns,
|
|
111
112
|
...tablePatterns,
|
|
113
|
+
...tablePaginationPatterns,
|
|
112
114
|
...capturePatterns,
|
|
113
115
|
...storagePatterns,
|
|
114
116
|
...focusPatterns,
|
|
@@ -3,6 +3,7 @@ import { formatToRegexLiteral } from '@sun-asterisk/sungen';
|
|
|
3
3
|
import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
|
|
4
4
|
import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
|
|
5
5
|
import { pathToRegexSource } from '../utils/url-path-regex';
|
|
6
|
+
import { resolveColumnHeaderName } from '../utils/column-header';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Assertion patterns: visibility, text content, state, attributes
|
|
@@ -30,6 +31,19 @@ function qualifiedState(text: string): { assertion: string; label: string; probe
|
|
|
30
31
|
* locator strict-mode-fails. The role-strategy counterpart is LIST_MEMBER_ROLES. */
|
|
31
32
|
const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
|
|
32
33
|
|
|
34
|
+
/** Resolved strategies that pin a node by IDENTITY rather than by the text it renders. A
|
|
35
|
+
* `see [X] label with {{v}}` step honours these as real locators and asserts their text (#666).
|
|
36
|
+
*
|
|
37
|
+
* The omissions are deliberate, not an oversight: `text` finds the node by the very caption the
|
|
38
|
+
* label pattern is about, so honouring it would assert the caption contains the value; `label`
|
|
39
|
+
* (getByLabel) and `placeholder` resolve the labelled CONTROL, whose content is a `value`, not
|
|
40
|
+
* text; `role` names an element through its accessible name, which for a caption IS the caption.
|
|
41
|
+
* Each of those keeps the caption+value text reading. */
|
|
42
|
+
const IDENTITY_STRATEGIES = new Set([
|
|
43
|
+
'testid', 'id', 'css', 'locator', 'xpath',
|
|
44
|
+
'accessibility-id', 'android-uiautomator', 'ios-predicate',
|
|
45
|
+
]);
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
|
|
35
49
|
* MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
|
|
@@ -68,6 +82,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
68
82
|
// Column cell assertion: "see [Department] column 1 with {{value}}" -> check table cell text
|
|
69
83
|
{
|
|
70
84
|
name: 'column-cell-assertion',
|
|
85
|
+
platforms: ['web'],
|
|
86
|
+
platformAlternative:
|
|
87
|
+
'HTML table roles have no native equivalent - assert the row element itself by accessibility-id (`Then User see [Row 2 Amount] has text {{v}}`), or tag the scenario @manual',
|
|
71
88
|
matcher: (step: ParsedStep) =>
|
|
72
89
|
(step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
|
|
73
90
|
!!step.selectorRef &&
|
|
@@ -86,10 +103,15 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
86
103
|
// Create a safe variable name from column name
|
|
87
104
|
const columnIndexVar = `colIndex${step.selectorRef!.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
88
105
|
|
|
106
|
+
// The header TEXT this template matches on — both the visibility assertion and the
|
|
107
|
+
// findIndex scan — is declarable; the bracket label is only the fallback (issue #674).
|
|
108
|
+
// No table is named by this step form, so only the `<col>--columnheader` entry applies.
|
|
109
|
+
const columnName = resolveColumnHeaderName(step.selectorRef!, context);
|
|
110
|
+
|
|
89
111
|
return {
|
|
90
112
|
templateName: 'column-cell-assertion',
|
|
91
113
|
data: {
|
|
92
|
-
columnName
|
|
114
|
+
columnName,
|
|
93
115
|
rowNth,
|
|
94
116
|
columnIndexVar,
|
|
95
117
|
dataValue,
|
|
@@ -344,16 +366,26 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
344
366
|
// aria-selected — their state is `checked`; other targets probe at runtime.
|
|
345
367
|
let stateAssertion = state.assertion;
|
|
346
368
|
let selectedProbe = state.probe === true;
|
|
369
|
+
// A checked/unchecked assertion must NOT compile to toBeChecked() — it throws
|
|
370
|
+
// "Not a checkbox or radio button" on an aria-pressed toggle exactly as the action side did
|
|
371
|
+
// (#675). Flag it so the template can route it through expectCheckedState instead, which
|
|
372
|
+
// reads the same signals setCheckedState drives.
|
|
373
|
+
let isCheckedAssertion = state.label === 'checked' || state.label === 'unchecked';
|
|
374
|
+
let checkedWant = state.label === 'checked';
|
|
347
375
|
if (selectedProbe && resolved.role && ['checkbox', 'radio', 'switch'].includes(resolved.role)) {
|
|
348
376
|
stateAssertion = 'toBeChecked()';
|
|
349
377
|
selectedProbe = false;
|
|
378
|
+
isCheckedAssertion = true;
|
|
379
|
+
checkedWant = true;
|
|
350
380
|
}
|
|
351
|
-
|
|
352
381
|
const code = context.templateEngine.renderStep('state-with-filter-assertion', {
|
|
353
382
|
...resolved,
|
|
383
|
+
selectorRef: step.selectorRef,
|
|
354
384
|
dataValue,
|
|
355
385
|
stateAssertion,
|
|
356
386
|
selectedProbe,
|
|
387
|
+
isCheckedAssertion,
|
|
388
|
+
checkedWant,
|
|
357
389
|
});
|
|
358
390
|
return { code, comment: `Assert ${step.selectorRef} with ${step.dataRef} is ${state.label}` };
|
|
359
391
|
},
|
|
@@ -466,21 +498,40 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
466
498
|
|
|
467
499
|
// --- Label-value pattern: "User see [X] label with {{Y}}" ---
|
|
468
500
|
if (step.elementType === 'label' && step.dataRef) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
501
|
+
// A `label` ref reads two different ways, and the selectors.yaml entry decides which.
|
|
502
|
+
//
|
|
503
|
+
// NO ENTRY, or an entry that finds its element BY TEXT — the ref names on-screen
|
|
504
|
+
// CAPTION text rather than an element, so the assertion is "the screen shows
|
|
505
|
+
// `X <value>`". That is what label-value-assertion renders, and it stays the default.
|
|
506
|
+
//
|
|
507
|
+
// AN ENTRY NAMING A CONCRETE NODE (xpath / accessibility-id / testid / id / css / …) —
|
|
508
|
+
// the author has said WHERE the value lives. That locator used to be resolved and then
|
|
509
|
+
// thrown away, leaving a page-wide text match: on Android `UiSelector().text(v)`, which
|
|
510
|
+
// reads only @text, so a Flutter/RN node exposing @content-desc never matched. The
|
|
511
|
+
// entry was correct and the step failed every time (#666). Honouring it puts `label`
|
|
512
|
+
// alongside `header`, which never had the special case.
|
|
513
|
+
//
|
|
514
|
+
// CONTAINS, not exact: an entry for a label/value pair points legitimately either at
|
|
515
|
+
// the value node ("JD-001") or at the row holding both ("Job No: JD-001"), and the web
|
|
516
|
+
// regex it replaces was itself anchored only at the end. `exact: true` opts into the
|
|
517
|
+
// strict comparison.
|
|
518
|
+
//
|
|
519
|
+
// `resolved` is the SAME lookup, already performed above — this branch used to repeat it.
|
|
520
|
+
if (IDENTITY_STRATEGIES.has(resolved.strategy)) {
|
|
521
|
+
const code = context.templateEngine.renderStep(
|
|
522
|
+
resolved.exact ? 'have-text-assertion' : 'contain-text-assertion',
|
|
523
|
+
{ ...resolved, expectedText: dataValue },
|
|
476
524
|
);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// No selector entry — use label from selectorRef
|
|
525
|
+
return {
|
|
526
|
+
code,
|
|
527
|
+
comment: `Assert ${step.selectorRef} label ${resolved.exact ? 'has' : 'contains'} value ${step.dataRef}`,
|
|
528
|
+
};
|
|
482
529
|
}
|
|
483
530
|
|
|
531
|
+
// `value: ''` is the declared opt-out: drop the caption prefix, match the value alone.
|
|
532
|
+
const label = resolved.value !== undefined && resolved.value.trim() === ''
|
|
533
|
+
? undefined
|
|
534
|
+
: step.selectorRef;
|
|
484
535
|
const code = context.templateEngine.renderStep('label-value-assertion', {
|
|
485
536
|
label,
|
|
486
537
|
dataValue,
|
|
@@ -922,6 +973,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
922
973
|
},
|
|
923
974
|
{
|
|
924
975
|
name: 'is-sorted-ascending',
|
|
976
|
+
platforms: ['web'],
|
|
977
|
+
platformAlternative:
|
|
978
|
+
'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
|
|
925
979
|
states: ['sorted ascending'],
|
|
926
980
|
matcher: (step: ParsedStep) =>
|
|
927
981
|
/\b(see|sees)\s+\[/.test(step.text) && /\bsorted ascending\b/.test(step.text) && !!step.selectorRef,
|
|
@@ -937,6 +991,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
937
991
|
},
|
|
938
992
|
{
|
|
939
993
|
name: 'is-sorted-descending',
|
|
994
|
+
platforms: ['web'],
|
|
995
|
+
platformAlternative:
|
|
996
|
+
'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
|
|
940
997
|
states: ['sorted descending'],
|
|
941
998
|
matcher: (step: ParsedStep) =>
|
|
942
999
|
/\b(see|sees)\s+\[/.test(step.text) && /\bsorted descending\b/.test(step.text) && !!step.selectorRef,
|
|
@@ -30,6 +30,9 @@ export const capturePatterns: StepPattern[] = [
|
|
|
30
30
|
// NOTE: the `table-` name prefix opts out of generateStep's parentRef auto-scoping
|
|
31
31
|
// (the table locator is resolved internally from the step text, like all table-* patterns).
|
|
32
32
|
name: 'table-capture-row-nth',
|
|
33
|
+
platforms: ['web'],
|
|
34
|
+
platformAlternative:
|
|
35
|
+
'there is no HTML table on a native screen - remember the value from the row element itself with the generic capture step (`User remember [Row 2 Amount] as {{v}}`)',
|
|
33
36
|
matcher: (step: ParsedStep) =>
|
|
34
37
|
/\bremember\b/i.test(step.text) && /\bas\s+\{\{[^}]+\}\}/i.test(step.text) && TABLE_ROW_NTH.test(step.text),
|
|
35
38
|
resolver: (step, context): StepTemplateData => {
|
|
@@ -30,6 +30,9 @@ const CELL = /^\s*\[([^\]]+)\]\s*([A-Za-z][A-Za-z-]*)?\s*$/;
|
|
|
30
30
|
export const focusPatterns: StepPattern[] = [
|
|
31
31
|
{
|
|
32
32
|
name: 'tab-order-assertion',
|
|
33
|
+
platforms: ['web'],
|
|
34
|
+
platformAlternative:
|
|
35
|
+
'a touch screen has no keyboard focus traversal - run the accessibility audit manually and tag the scenario @manual:M10',
|
|
33
36
|
matcher: (step) => reTabOrder.test(step.text),
|
|
34
37
|
resolver: (step, context) => {
|
|
35
38
|
const table = step.dataTable;
|
|
@@ -7,6 +7,9 @@ import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sunge
|
|
|
7
7
|
export const formPatterns: StepPattern[] = [
|
|
8
8
|
{
|
|
9
9
|
name: 'upload-file',
|
|
10
|
+
platforms: ['web'],
|
|
11
|
+
platformAlternative:
|
|
12
|
+
'the file picker is a separate app on mobile - push the file with the driver and drive that picker app-specifically, or tag the scenario @manual:M10',
|
|
10
13
|
matcher: (step: ParsedStep) =>
|
|
11
14
|
(step.text.includes('fill') || step.text.includes('fills')) &&
|
|
12
15
|
step.elementType === 'uploader' &&
|
|
@@ -97,8 +100,15 @@ export const formPatterns: StepPattern[] = [
|
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
103
|
+
// Rich-text editors fill with pressSequentially, not .fill(). Two ways in: the selector
|
|
104
|
+
// entry (`contenteditable: true`, which the resolver translates into `inputMethod`, #712 —
|
|
105
|
+
// or `inputMethod` written out), and the Gherkin `editor` element type, which is a property
|
|
106
|
+
// of the STEP and so is read separately.
|
|
107
|
+
//
|
|
108
|
+
// The note that used to sit here said live-scan auto-detected this. Nothing in any package
|
|
109
|
+
// writes `inputMethod` — that was the same false-consumer claim #712 was filed over, and it
|
|
110
|
+
// mattered: with `contenteditable:` dead and no auto-detection, an author who hit a
|
|
111
|
+
// rich-text field had no working field to reach for at all.
|
|
102
112
|
const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
|
|
103
113
|
const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
|
|
104
114
|
|
|
@@ -114,8 +124,13 @@ export const formPatterns: StepPattern[] = [
|
|
|
114
124
|
},
|
|
115
125
|
{
|
|
116
126
|
name: 'check-checkbox',
|
|
127
|
+
// `\bchecks?\b` rather than `includes('checks')`: "unchecks" CONTAINS "checks", and this
|
|
128
|
+
// pattern is declared before uncheck-checkbox at the same priority 8 — so the substring test
|
|
129
|
+
// made every `unchecks [X]` step compile to a CHECK, turning the control ON and even labelling
|
|
130
|
+
// the comment "Check". A word boundary cannot match inside "unchecks" (both neighbours are word
|
|
131
|
+
// characters), so it separates the two verbs on its own.
|
|
117
132
|
matcher: (step: ParsedStep) =>
|
|
118
|
-
(step.text
|
|
133
|
+
/\bchecks?\b/.test(step.text) && !!step.selectorRef,
|
|
119
134
|
resolver: (step, context) => {
|
|
120
135
|
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
121
136
|
return {
|
|
@@ -128,8 +143,11 @@ export const formPatterns: StepPattern[] = [
|
|
|
128
143
|
},
|
|
129
144
|
{
|
|
130
145
|
name: 'uncheck-checkbox',
|
|
146
|
+
// One regex covers both forms: `\buncheck\b` alone cannot match "unchecks" (the trailing "s"
|
|
147
|
+
// is a word character, so there is no boundary after "k") — which is why the substring test
|
|
148
|
+
// was here in the first place.
|
|
131
149
|
matcher: (step: ParsedStep) =>
|
|
132
|
-
(step.text
|
|
150
|
+
/\bunchecks?\b/.test(step.text) && !!step.selectorRef,
|
|
133
151
|
resolver: (step, context) => {
|
|
134
152
|
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
135
153
|
return {
|
|
@@ -32,6 +32,54 @@ function elementKeywordToRole(text: string): string | null {
|
|
|
32
32
|
/**
|
|
33
33
|
* Interaction patterns: click, hover, press, wait, alert
|
|
34
34
|
*/
|
|
35
|
+
/** Corner presets: 5% in from each corner, so the point stays on the element under any padding. */
|
|
36
|
+
const CLICK_POSITION_PRESETS: Record<string, { x: number; y: number }> = {
|
|
37
|
+
'top-left': { x: 5, y: 5 },
|
|
38
|
+
'top-right': { x: 95, y: 5 },
|
|
39
|
+
'bottom-left': { x: 5, y: 95 },
|
|
40
|
+
'bottom-right': { x: 95, y: 95 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A parsed click position: each axis is `px` or `%` of the element's padding box (ratio precomputed). */
|
|
44
|
+
export interface ClickPosition {
|
|
45
|
+
x: number; xUnit: 'px' | '%'; xRatio: number;
|
|
46
|
+
y: number; yUnit: 'px' | '%'; yRatio: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse the `at position "<preset>"` / `at offset x=<n>px|%, y=<n>px|%` suffix. Strict on purpose:
|
|
51
|
+
* anything the grammar does not accept is an error the compiler reports — a position that is half
|
|
52
|
+
* understood would click somewhere the author did not ask for.
|
|
53
|
+
*/
|
|
54
|
+
export function parseClickPosition(text: string): ClickPosition | { error: string } {
|
|
55
|
+
const presetNames = Object.keys(CLICK_POSITION_PRESETS).map((k) => `"${k}"`).join(' | ');
|
|
56
|
+
// Both forms end the sentence: anything after the position is text no form reads, so it is refused
|
|
57
|
+
// rather than carried along silently.
|
|
58
|
+
const preset = /\bat\s+position\s+"([^"]*)"\s*$/i.exec(text);
|
|
59
|
+
if (preset) {
|
|
60
|
+
const p = CLICK_POSITION_PRESETS[preset[1].trim().toLowerCase()];
|
|
61
|
+
if (!p) return { error: `unknown click position preset "${preset[1]}" — use ${presetNames}` };
|
|
62
|
+
return { x: p.x, xUnit: '%', xRatio: p.x / 100, y: p.y, yUnit: '%', yRatio: p.y / 100 };
|
|
63
|
+
}
|
|
64
|
+
if (/\bat\s+position\b/i.test(text)) return { error: `\`at position\` needs a quoted preset (${presetNames}) as the last thing in the step` };
|
|
65
|
+
const offset = /\bat\s+offset\s+x\s*=\s*(-?\d+(?:\.\d+)?)\s*(px|%)\s*,\s*y\s*=\s*(-?\d+(?:\.\d+)?)\s*(px|%)\s*$/i.exec(text);
|
|
66
|
+
if (!offset) return { error: '`at offset` needs `x=<n>px|%, y=<n>px|%` with both axes (e.g. `x=24px, y=10%`) as the last thing in the step' };
|
|
67
|
+
const axis = (raw: string, unit: string, name: 'x' | 'y'): { value: number; unit: 'px' | '%' } | { error: string } => {
|
|
68
|
+
const value = Number(raw);
|
|
69
|
+
if (value < 0) return { error: `${name}=${raw}${unit} is negative — offsets are measured from the element's top-left corner` };
|
|
70
|
+
if (unit === '%' && value > 100) return { error: `${name}=${raw}% exceeds 100% of the element` };
|
|
71
|
+
return { value, unit: unit.toLowerCase() as 'px' | '%' };
|
|
72
|
+
};
|
|
73
|
+
const x = axis(offset[1], offset[2], 'x');
|
|
74
|
+
if ('error' in x) return x;
|
|
75
|
+
const y = axis(offset[3], offset[4], 'y');
|
|
76
|
+
if ('error' in y) return y;
|
|
77
|
+
return {
|
|
78
|
+
x: x.value, xUnit: x.unit, xRatio: x.unit === '%' ? x.value / 100 : x.value,
|
|
79
|
+
y: y.value, yUnit: y.unit, yRatio: y.unit === '%' ? y.value / 100 : y.value,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
35
83
|
export const interactionPatterns: StepPattern[] = [
|
|
36
84
|
// === Browser Alert (system dialog) patterns ===
|
|
37
85
|
// Compound form (preferred since v3.3): trigger + dialog response in ONE step, in the
|
|
@@ -106,6 +154,9 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
106
154
|
},
|
|
107
155
|
{
|
|
108
156
|
name: 'alert-fill',
|
|
157
|
+
platforms: ['web'],
|
|
158
|
+
platformAlternative:
|
|
159
|
+
'Appium ships no fill for a native prompt - accept or dismiss the alert instead (the `alert` element type still works), drive the app own text field as a normal element, or tag the scenario @manual:M10',
|
|
109
160
|
matcher: (step: ParsedStep) =>
|
|
110
161
|
step.elementType === 'alert' &&
|
|
111
162
|
step.text.includes('fill') &&
|
|
@@ -228,6 +279,52 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
228
279
|
},
|
|
229
280
|
priority: 14, // Below click-element-with-text (15) so known selectors use proper locator
|
|
230
281
|
},
|
|
282
|
+
{
|
|
283
|
+
// `click [X] overlay at position "top-left"` / `… at offset x=24px, y=10%` — a click AWAY from the
|
|
284
|
+
// centre, for backdrop-dismiss overlays and any control whose centre is covered by a child.
|
|
285
|
+
// `.click()` always lands on the centre; there was no way to say "click the overlay, not the
|
|
286
|
+
// dialog sitting in the middle of it". Presets are 5% in from the corners so the point stays on
|
|
287
|
+
// the element under any padding. Positions are checked against the element's PADDING box at
|
|
288
|
+
// run time (`clientWidth/Height`, the same box `click({ position })` measures from), so a px
|
|
289
|
+
// offset beyond the real size fails with the real size in the message instead of clicking the
|
|
290
|
+
// neighbour. A suffix the grammar cannot parse is refused at compile time (SG-E026, via the core
|
|
291
|
+
// guard) — never a silent centre click.
|
|
292
|
+
name: 'click-element-at-position',
|
|
293
|
+
platforms: ['web'],
|
|
294
|
+
platformAlternative:
|
|
295
|
+
'Appium has no positioned click on an element — tap the control the position targets directly (`User tap [X] button`), or tag the scenario @manual',
|
|
296
|
+
matcher: (step: ParsedStep) =>
|
|
297
|
+
/\b(click|clicks)\b/.test(step.text)
|
|
298
|
+
// a positioned DOUBLE/RIGHT click is not offered — leave those to their own forms so the core
|
|
299
|
+
// guard refuses the suffix instead of this form silently turning them into a single click
|
|
300
|
+
&& !/\b(?:double|right)[\s-]*click/i.test(step.text)
|
|
301
|
+
&& /\bat\s+(position|offset)\b/i.test(step.text) && !!step.selectorRef,
|
|
302
|
+
resolver: (step, context) => {
|
|
303
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
304
|
+
step.selectorRef!, undefined, step.elementType, step.nth
|
|
305
|
+
);
|
|
306
|
+
const suffix = step.text.match(/\bat\s+(?:position|offset)\b.*$/i)?.[0].trim() ?? 'at position';
|
|
307
|
+
// JSON.stringify quotes the label for the generated code — the step text is author text.
|
|
308
|
+
const label = JSON.stringify(`click [${step.selectorRef}] ${suffix}`);
|
|
309
|
+
const parsed = parseClickPosition(step.text);
|
|
310
|
+
if ('error' in parsed) {
|
|
311
|
+
// The template is not what refuses this — the core compiler does (SG-E026 + a loud throw);
|
|
312
|
+
// `positionError` is the signal it reads. Emitted here so the reason names the exact fault.
|
|
313
|
+
return {
|
|
314
|
+
templateName: 'click-at-position-action',
|
|
315
|
+
data: { ...resolved, selectorRef: step.selectorRef, label, positionError: parsed.error },
|
|
316
|
+
comment: `Click ${step.selectorRef} ${suffix}`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
templateName: 'click-at-position-action',
|
|
321
|
+
data: { ...resolved, selectorRef: step.selectorRef, label, clickPosition: parsed },
|
|
322
|
+
comment: `Click ${step.selectorRef} ${suffix}`,
|
|
323
|
+
};
|
|
324
|
+
},
|
|
325
|
+
priority: 12, // above click-element (10): the suffix wins; below click-element-with-text (15) and the
|
|
326
|
+
// alert/try-to forms — those combinations are refused by the core guard, not compiled
|
|
327
|
+
},
|
|
231
328
|
{
|
|
232
329
|
name: 'click-element-with-text',
|
|
233
330
|
matcher: (step: ParsedStep) =>
|
|
@@ -295,6 +392,12 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
295
392
|
// click-element-with-text. Without this, hover fell through to hover-element which IGNORED
|
|
296
393
|
// the value and hovered the first match (strict-mode / wrong element on dynamic lists).
|
|
297
394
|
name: 'hover-element-with-text',
|
|
395
|
+
// Pure: this resolver requests `hover-element-with-text` and nothing else, which is what makes
|
|
396
|
+
// the declaration safe. A pattern that can reach a shared template (click, recipe-sequence) must
|
|
397
|
+
// NOT be declared — see the mixed `unknown-element-action` below, which also serves click.
|
|
398
|
+
platforms: ['web'],
|
|
399
|
+
platformAlternative:
|
|
400
|
+
'a touch screen has no hover, so filtering a list by text and hovering it has no mobile meaning — tap the matching item with `User tap [X] item with {{value}}`, or tag the scenario @manual:M10',
|
|
298
401
|
matcher: (step: ParsedStep) =>
|
|
299
402
|
(step.text.includes('hovers') || step.text.match(/\bhover\b/))
|
|
300
403
|
&& !!step.selectorRef
|
|
@@ -328,6 +431,10 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
328
431
|
},
|
|
329
432
|
{
|
|
330
433
|
name: 'hover-element',
|
|
434
|
+
// Pure: requests `hover-action` only.
|
|
435
|
+
platforms: ['web'],
|
|
436
|
+
platformAlternative:
|
|
437
|
+
'there is no hover on a touch screen — content a hover reveals is normally already visible on mobile; if the interaction is genuinely required, tap it with `User tap [X]`, otherwise tag the scenario @manual:M10',
|
|
331
438
|
matcher: (step: ParsedStep) =>
|
|
332
439
|
(step.text.includes('hovers') || step.text.match(/\bhover\b/)) && !!step.selectorRef,
|
|
333
440
|
resolver: (step, context) => {
|
|
@@ -457,19 +564,23 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
457
564
|
|
|
458
565
|
const isAbsoluteUrl = /^https?:\/\//.test(path);
|
|
459
566
|
let pathRegex: string;
|
|
567
|
+
let hostRegex: string | undefined;
|
|
460
568
|
if (isAbsoluteUrl) {
|
|
461
569
|
const url = new URL(path);
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
570
|
+
// Host and path are matched SEPARATELY since #677: the wait tests `u.hostname` and
|
|
571
|
+
// `u.pathname`, and neither string contains the other. Concatenating them into one regex
|
|
572
|
+
// only worked while the match ran unanchored across the whole URL — the very looseness
|
|
573
|
+
// that let the wait resolve on a transient URL. Dynamic :id segments still wildcard;
|
|
574
|
+
// the host stays a literal.
|
|
575
|
+
hostRegex = escapeRegex(url.hostname);
|
|
576
|
+
pathRegex = pathToRegexSource(url.pathname);
|
|
466
577
|
} else {
|
|
467
578
|
pathRegex = pathToRegexSource(path);
|
|
468
579
|
}
|
|
469
580
|
|
|
470
581
|
return {
|
|
471
582
|
templateName: 'wait-for-page',
|
|
472
|
-
data: { pathRegex },
|
|
583
|
+
data: { pathRegex, hostRegex },
|
|
473
584
|
comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
|
|
474
585
|
};
|
|
475
586
|
},
|
|
@@ -172,6 +172,9 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
name: 'route-assertion',
|
|
175
|
+
platforms: ['web'],
|
|
176
|
+
platformAlternative:
|
|
177
|
+
'a native app has no URL - assert a marker element on the destination screen (`Then User see [Header Title] is visible`) plus the origin marker `is hidden`',
|
|
175
178
|
matcher: (step: ParsedStep) =>
|
|
176
179
|
(step.text.includes('should see route') || step.text.includes('should remain on')) &&
|
|
177
180
|
!!(step.selectorRef || step.dataRef),
|
|
@@ -104,6 +104,9 @@ function buildCheck(text: string, area: string): StorageCheck | null {
|
|
|
104
104
|
export const storagePatterns: StepPattern[] = [
|
|
105
105
|
{
|
|
106
106
|
name: 'storage-key-assertion',
|
|
107
|
+
platforms: ['web'],
|
|
108
|
+
platformAlternative:
|
|
109
|
+
'browser local/session storage has no native equivalent - assert the state through a UI element, or through @api/@query against the backend',
|
|
107
110
|
matcher: (step) =>
|
|
108
111
|
reExists.test(step.text) || reNotExist.test(step.text) ||
|
|
109
112
|
reNotEmpty.test(step.text) || reHasLength.test(step.text),
|
|
@@ -130,6 +133,9 @@ export const storagePatterns: StepPattern[] = [
|
|
|
130
133
|
},
|
|
131
134
|
{
|
|
132
135
|
name: 'storage-key-matching-assertion',
|
|
136
|
+
platforms: ['web'],
|
|
137
|
+
platformAlternative:
|
|
138
|
+
'browser local/session storage has no native equivalent - assert the state through a UI element, or through @api/@query against the backend',
|
|
133
139
|
matcher: (step) => reKeyMatching.test(step.text),
|
|
134
140
|
resolver: (step) => {
|
|
135
141
|
const area = storageArea(step.text);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table Pagination Patterns (issue #712)
|
|
3
|
+
*
|
|
4
|
+
* Syntax:
|
|
5
|
+
* - User go to next page in [Orders] table
|
|
6
|
+
* - User go to prev page in [Orders] table # `previous` accepted too
|
|
7
|
+
*
|
|
8
|
+
* These are the consumers for the table entry's `pagination:` map, which was documented in the
|
|
9
|
+
* selector reference and plumbed onto ResolvedSelector while nothing read it back — writing it
|
|
10
|
+
* changed nothing, with no diagnostic (#712, the same drift class as #690).
|
|
11
|
+
*
|
|
12
|
+
* orders:
|
|
13
|
+
* type: 'table'
|
|
14
|
+
* value: 'table.orders'
|
|
15
|
+
* pagination:
|
|
16
|
+
* next: '[data-testid="next-page"]'
|
|
17
|
+
* prev: '[data-testid="prev-page"]'
|
|
18
|
+
*
|
|
19
|
+
* Three deliberate choices:
|
|
20
|
+
*
|
|
21
|
+
* PAGE-LEVEL, NOT TABLE-SCOPED. The control is located from `page`, not from inside the table
|
|
22
|
+
* locator, because in every component library that ships a paginator (antd, MUI, Radix) the
|
|
23
|
+
* control is a SIBLING of the <table>, not a descendant. Scoping it to the table would resolve 0
|
|
24
|
+
* elements for the common case — the same vacuous-universe failure `rows:` exists to fix
|
|
25
|
+
* (SG-W016). The field lives on the table entry because it BELONGS to that table logically, not
|
|
26
|
+
* because it is nested under it in the DOM.
|
|
27
|
+
*
|
|
28
|
+
* NO IMPLICIT WAIT. Clicking next does one thing: click. The round-trip afterwards is
|
|
29
|
+
* `User wait for [Orders] table to refresh`, which already owns signal resolution (aria-busy,
|
|
30
|
+
* then the profile's loading indicator). Folding a hidden wait in here would duplicate that
|
|
31
|
+
* logic and hide which signal was used. A disabled control (already on the last page) needs no
|
|
32
|
+
* guard either: Playwright's click waits for enabled and fails loud, so the step cannot pass
|
|
33
|
+
* without having done anything.
|
|
34
|
+
*
|
|
35
|
+
* REFUSAL VIA THE SHARED PATH, NOT A WEB TEMPLATE. An undeclared direction reports SG-W017 and
|
|
36
|
+
* compiles through `unsupportedStepCode`, rather than rendering a refusal template of its own:
|
|
37
|
+
* playwright's refusal-only template count is a deliberate floor of 0 (refusals there are the
|
|
38
|
+
* platform's job, not a step's), and a one-sided .hbs would fail the adapter-parity gate.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { StepPattern, PatternContext, MappedStep, unsupportedStepCode } from '@sun-asterisk/sungen';
|
|
42
|
+
|
|
43
|
+
/** `go to next page in` / `goes to previous page in` — the direction is captured. */
|
|
44
|
+
const PAGINATE_STEP = /\bgo(?:es)?\s+to\s+(next|prev|previous)\s+page\s+in\b/i;
|
|
45
|
+
|
|
46
|
+
/** Gherkin direction word → the key authors write under `pagination:` in selectors.yaml. */
|
|
47
|
+
function paginationKey(direction: string): 'next' | 'prev' {
|
|
48
|
+
return /^next$/i.test(direction) ? 'next' : 'prev';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const tablePaginationPatterns: StepPattern[] = [
|
|
52
|
+
{
|
|
53
|
+
name: 'table-paginate',
|
|
54
|
+
platforms: ['web'],
|
|
55
|
+
platformAlternative:
|
|
56
|
+
'a native screen has no HTML paginator - scroll the list instead (`When User scroll to [Last Row]`), or tap the pager control directly as a plain tap step',
|
|
57
|
+
matcher: (step) => PAGINATE_STEP.test(step.text) && /\btable\b/i.test(step.text),
|
|
58
|
+
generator: (step, context: PatternContext): MappedStep => {
|
|
59
|
+
const tableName = (step.text.match(/\[([^\]]+)\]/)?.[1] ?? '').trim();
|
|
60
|
+
const direction = step.text.match(PAGINATE_STEP)![1];
|
|
61
|
+
const key = paginationKey(direction);
|
|
62
|
+
|
|
63
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
64
|
+
tableName, context.featureName, 'table', 0,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const declared = resolved.pagination?.[key];
|
|
68
|
+
const paginationSelector = typeof declared === 'string' ? declared.trim() : '';
|
|
69
|
+
|
|
70
|
+
// Nothing honest to emit: refuse at compile time and name the field to add. Guessing a
|
|
71
|
+
// locator here (hunting a button called `Next`) is exactly what #690 punished.
|
|
72
|
+
if (!paginationSelector) {
|
|
73
|
+
const known = Object.keys(resolved.pagination ?? {});
|
|
74
|
+
const found = known.length
|
|
75
|
+
? `that entry declares pagination.${known.join('/')} only`
|
|
76
|
+
: 'that entry declares no pagination at all';
|
|
77
|
+
const reason =
|
|
78
|
+
`step "${step.text}" needs "pagination.${key}" on the [${tableName}] table entry, but `
|
|
79
|
+
+ `${found}. Add it in selectors.yaml, e.g. pagination: { ${key}: '[data-testid="${key}-page"]' }.`;
|
|
80
|
+
|
|
81
|
+
context.diagnostics?.add('SG-W017', reason, {
|
|
82
|
+
feature: context.featureName,
|
|
83
|
+
step: step.text,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
code: unsupportedStepCode(reason),
|
|
87
|
+
comment: `pagination.${key} not declared for ${tableName} table — see SG-W017`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
code: context.templateEngine.renderStep('table-paginate-action', {
|
|
93
|
+
...resolved, paginationSelector, direction: key, tableName,
|
|
94
|
+
}),
|
|
95
|
+
comment: `Go to ${key === 'next' ? 'next' : 'previous'} page in ${tableName} table`,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
// Above the table family (10-20) so the strict matcher is never shadowed by a generic
|
|
99
|
+
// table step; `go to` is claimed by no navigation pattern (those match open/is on/navigate to).
|
|
100
|
+
priority: 21,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
@@ -17,6 +17,63 @@
|
|
|
17
17
|
|
|
18
18
|
import { StepPattern, PatternContext } from '@sun-asterisk/sungen';
|
|
19
19
|
import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
|
|
20
|
+
import { resolveColumnHeaderName } from '../utils/column-header';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Row LABEL resolution — `[First Contact Row] row in [T] table` (cb59e157, SG-W010).
|
|
24
|
+
*
|
|
25
|
+
* A `type: locator` entry for the row label positions ONE row (`tbody tr:first-child`, relative to
|
|
26
|
+
* the table) and the step asserts that exact row. No entry → the label is display-only and the
|
|
27
|
+
* historical filter-by-value query runs, byte-identical. Shared by the exists/hidden twins so the
|
|
28
|
+
* two can never drift apart again — `is hidden` used to compute the label and use it only in the
|
|
29
|
+
* comment (issue #688).
|
|
30
|
+
*
|
|
31
|
+
* NOT the row SHAPE. `div.job-row` (what every row matches) is a property of the table and lives on
|
|
32
|
+
* the table entry as `rows:`; put through this branch it strict-mode-fails, which is what the
|
|
33
|
+
* runtime `toHaveCount(1)` guard in the positioned templates now says out loud.
|
|
34
|
+
*/
|
|
35
|
+
function resolveRowLabel(rowLabel: string, tableName: string, context: PatternContext): string | undefined {
|
|
36
|
+
if (!rowLabel || rowLabel === tableName) return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const rowResolved = context.selectorResolver.resolveSelector(rowLabel, context.featureName, 'row', 0);
|
|
39
|
+
if (rowResolved.strategy === 'locator' && rowResolved.value && rowResolved.value.trim()) {
|
|
40
|
+
return rowResolved.value;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// no selectors.yaml entry for the row label — display-only label (fallback)
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* SG-W016 — a table located by CSS that declares no `rows:` (issue #688).
|
|
50
|
+
*
|
|
51
|
+
* Every table template assumes an ARIA table: rows are `getByRole('row')`. A list built from plain
|
|
52
|
+
* <div>s has no role=row, so that universe is 0 elements — `is hidden` and `is empty` then pass
|
|
53
|
+
* without checking anything, counts and clicks time out. The compiler cannot see the DOM, so this is
|
|
54
|
+
* a heuristic and a WARNING: a CSS/testid-located table whose value does not even mention `table`
|
|
55
|
+
* is the shape where the assumption most often fails. `role: table`, `table.users`, or a declared
|
|
56
|
+
* `rows:` are all evidence the author has thought about it, and stay silent. Once per table per
|
|
57
|
+
* feature — the same table is named by every row step of a scenario.
|
|
58
|
+
*/
|
|
59
|
+
const warnedTables = new WeakMap<object, Set<string>>();
|
|
60
|
+
function warnIfRowsLikelyMissing(
|
|
61
|
+
resolved: { strategy?: string; value?: string; rows?: string }, tableName: string,
|
|
62
|
+
context: PatternContext, stepText: string,
|
|
63
|
+
): void {
|
|
64
|
+
const sink = context.diagnostics;
|
|
65
|
+
if (!sink || resolved.rows) return;
|
|
66
|
+
if (!['css', 'locator', 'testid', 'id'].includes(String(resolved.strategy))) return;
|
|
67
|
+
if (/\btable\b/i.test(String(resolved.value ?? ''))) return;
|
|
68
|
+
const key = `${context.featureName ?? ''}::${tableName}`;
|
|
69
|
+
let seen = warnedTables.get(sink);
|
|
70
|
+
if (!seen) { seen = new Set(); warnedTables.set(sink, seen); }
|
|
71
|
+
if (seen.has(key)) return;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
sink.add('SG-W016',
|
|
74
|
+
`table "[${tableName}]" is located by ${resolved.strategy} ("${resolved.value}") and declares no "rows:" — if its rows are not role="row" (a <div> list), every table step resolves 0 rows: "is hidden" and "is empty" pass without checking anything, counts and clicks time out. Declare the row shape on the table entry, e.g. "rows: 'div.job-row'", or locate the table by role: table.`,
|
|
75
|
+
{ feature: context.featureName, step: stepText });
|
|
76
|
+
}
|
|
20
77
|
|
|
21
78
|
/** Helper: check if current step is in a Given context */
|
|
22
79
|
function isGivenContext(context: PatternContext): boolean {
|
|
@@ -38,6 +95,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
38
95
|
// "User see [Table] table with {{count}}" — row count (no "rows" keyword needed)
|
|
39
96
|
{
|
|
40
97
|
name: 'table-row-count',
|
|
98
|
+
platforms: ['web'],
|
|
99
|
+
platformAlternative:
|
|
100
|
+
'there is no HTML table on a native screen - assert the list count through a visible counter element, or through @query against the backend',
|
|
41
101
|
matcher: (step) => {
|
|
42
102
|
return step.elementType === 'table' &&
|
|
43
103
|
/\btable\s+with\b/i.test(step.text) &&
|
|
@@ -51,6 +111,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
51
111
|
step.selectorRef!, context.featureName, 'table', step.nth
|
|
52
112
|
);
|
|
53
113
|
const count = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
114
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
54
115
|
|
|
55
116
|
const isGiven = isGivenContext(context);
|
|
56
117
|
return {
|
|
@@ -65,6 +126,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
65
126
|
// "User see [Col] column in [Table] table"
|
|
66
127
|
{
|
|
67
128
|
name: 'table-column-exists',
|
|
129
|
+
platforms: ['web'],
|
|
130
|
+
platformAlternative:
|
|
131
|
+
'there is no HTML table on a native screen - assert the header element by accessibility-id instead',
|
|
68
132
|
matcher: (step) => {
|
|
69
133
|
return /\bcolumn\s+in\b/i.test(step.text) &&
|
|
70
134
|
/\btable\b/i.test(step.text) &&
|
|
@@ -82,10 +146,14 @@ export const tablePatterns: StepPattern[] = [
|
|
|
82
146
|
tableName, context.featureName, 'table', step.nth
|
|
83
147
|
);
|
|
84
148
|
|
|
149
|
+
// The DOM header text is declarable — the bracket label is the TEST's name for the column,
|
|
150
|
+
// not the page's (issue #674). See resolveColumnHeaderName for the precedence.
|
|
151
|
+
const headerName = resolveColumnHeaderName(columnName, context, resolved);
|
|
152
|
+
|
|
85
153
|
const isGiven = isGivenContext(context);
|
|
86
154
|
return {
|
|
87
155
|
templateName: 'table-column-exists',
|
|
88
|
-
data: { ...resolved, columnName, isGiven },
|
|
156
|
+
data: { ...resolved, columnName: headerName, isGiven },
|
|
89
157
|
comment: `${isGiven ? 'Wait' : 'Assert'} ${columnName} column in ${tableName} table`,
|
|
90
158
|
};
|
|
91
159
|
},
|
|
@@ -95,6 +163,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
95
163
|
// "User see [Table] table is empty"
|
|
96
164
|
{
|
|
97
165
|
name: 'table-is-empty',
|
|
166
|
+
platforms: ['web'],
|
|
167
|
+
platformAlternative:
|
|
168
|
+
'there is no HTML table on a native screen - assert the empty-state element is visible and a row marker `is hidden`',
|
|
98
169
|
states: ['empty'],
|
|
99
170
|
matcher: (step) => {
|
|
100
171
|
return /\btable\s+is\s+empty\b/i.test(step.text);
|
|
@@ -104,6 +175,8 @@ export const tablePatterns: StepPattern[] = [
|
|
|
104
175
|
step.selectorRef!, context.featureName, 'table', step.nth
|
|
105
176
|
);
|
|
106
177
|
|
|
178
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
179
|
+
|
|
107
180
|
const isGiven = isGivenContext(context);
|
|
108
181
|
return {
|
|
109
182
|
templateName: 'table-empty',
|
|
@@ -151,6 +224,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
151
224
|
// "User see [Ref] row in [Table] table with {{value}}" — row exists (enters row scope)
|
|
152
225
|
{
|
|
153
226
|
name: 'table-row-exists',
|
|
227
|
+
platforms: ['web'],
|
|
228
|
+
platformAlternative:
|
|
229
|
+
'there is no HTML table on a native screen - assert the row element by accessibility-id (`Then User see [Row 1 Title] has text {{v}}`)',
|
|
154
230
|
matcher: (step) => {
|
|
155
231
|
return step.elementType === 'row' &&
|
|
156
232
|
/\bin\b.*\btable\b/i.test(step.text) &&
|
|
@@ -179,24 +255,13 @@ export const tablePatterns: StepPattern[] = [
|
|
|
179
255
|
// (RELATIVE to the table, e.g. `tbody tr:first-child`) positions the row,
|
|
180
256
|
// and the assertion becomes toContainText on that exact row. No entry →
|
|
181
257
|
// the historical filter-by-value behavior, byte-identical.
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
try {
|
|
185
|
-
const rowResolved = context.selectorResolver.resolveSelector(
|
|
186
|
-
rowLabel, context.featureName, 'row', 0
|
|
187
|
-
);
|
|
188
|
-
if (rowResolved.strategy === 'locator' && rowResolved.value && rowResolved.value.trim()) {
|
|
189
|
-
rowSelector = rowResolved.value;
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
// no selectors.yaml entry for the row label — display-only label (fallback)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
258
|
+
const rowSelector = resolveRowLabel(rowLabel, tableName, context);
|
|
259
|
+
if (!rowSelector) warnIfRowsLikelyMissing(resolved, tableName, context, step.text);
|
|
195
260
|
|
|
196
261
|
const isGiven = isGivenContext(context);
|
|
197
262
|
return {
|
|
198
263
|
templateName: 'table-row-exists',
|
|
199
|
-
data: { ...resolved, filterValue, isGiven, rowSelector },
|
|
264
|
+
data: { ...resolved, filterValue, isGiven, rowSelector, rowLabel },
|
|
200
265
|
comment: rowSelector
|
|
201
266
|
? `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} (positioned row) in ${tableName} table contains ${step.dataRef}`
|
|
202
267
|
: `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table with ${step.dataRef}`,
|
|
@@ -208,6 +273,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
208
273
|
// "User see [Ref] row in [Table] table with {{value}} is hidden"
|
|
209
274
|
{
|
|
210
275
|
name: 'table-row-hidden',
|
|
276
|
+
platforms: ['web'],
|
|
277
|
+
platformAlternative:
|
|
278
|
+
'there is no HTML table on a native screen - assert the row element `is hidden` by accessibility-id',
|
|
211
279
|
states: ['hidden'],
|
|
212
280
|
matcher: (step) => {
|
|
213
281
|
return step.elementType === 'row' &&
|
|
@@ -229,11 +297,18 @@ export const tablePatterns: StepPattern[] = [
|
|
|
229
297
|
);
|
|
230
298
|
const filterValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
231
299
|
|
|
300
|
+
// Same label contract as the exists twin (issue #688): a declared positioned row is asserted
|
|
301
|
+
// NOT to hold the value; an undeclared label keeps the filter-by-value count-0 query.
|
|
302
|
+
const rowSelector = resolveRowLabel(rowLabel, tableName, context);
|
|
303
|
+
if (!rowSelector) warnIfRowsLikelyMissing(resolved, tableName, context, step.text);
|
|
304
|
+
|
|
232
305
|
const isGiven = isGivenContext(context);
|
|
233
306
|
return {
|
|
234
307
|
templateName: 'table-row-not-exists',
|
|
235
|
-
data: { ...resolved, filterValue, isGiven },
|
|
236
|
-
comment:
|
|
308
|
+
data: { ...resolved, filterValue, isGiven, rowSelector, rowLabel },
|
|
309
|
+
comment: rowSelector
|
|
310
|
+
? `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} (positioned row) in ${tableName} table does not contain ${step.dataRef}`
|
|
311
|
+
: `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table is hidden`,
|
|
237
312
|
};
|
|
238
313
|
},
|
|
239
314
|
priority: 19,
|
|
@@ -247,6 +322,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
247
322
|
// where a re-render between queries hits two different rows. Row numbering follows
|
|
248
323
|
// qa/app.yaml conventions.table (row_base, header_is_row).
|
|
249
324
|
name: 'table-action-in-row-nth',
|
|
325
|
+
platforms: ['web'],
|
|
326
|
+
platformAlternative:
|
|
327
|
+
'there is no HTML table on a native screen - tap the control on the nth row element itself (`When User tap [Row 2 Delete] button`)',
|
|
250
328
|
matcher: (step) => {
|
|
251
329
|
return TABLE_ROW_NTH.test(step.text) &&
|
|
252
330
|
/\b(click|clicks|check|checks|uncheck|unchecks)\b/i.test(step.text) &&
|
|
@@ -262,20 +340,34 @@ export const tablePatterns: StepPattern[] = [
|
|
|
262
340
|
const tableResolved = context.selectorResolver.resolveSelector(
|
|
263
341
|
tableName, context.featureName, 'table', 0
|
|
264
342
|
);
|
|
343
|
+
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
265
344
|
|
|
266
|
-
|
|
267
|
-
|
|
345
|
+
// Plural forms are admitted by the matcher above, so they must be READ here too: the old
|
|
346
|
+
// `\b(click|check|uncheck)\b` matched none of "clicks"/"checks"/"unchecks" (the trailing "s"
|
|
347
|
+
// kills the closing boundary), so every plural step silently fell back to 'click' — an
|
|
348
|
+
// `unchecks` step compiled to a click. `uncheck` is listed before `check` so the longer verb
|
|
349
|
+
// wins the alternation.
|
|
350
|
+
const actionMatch = step.text.match(/\b(clicks?|unchecks?|checks?)\b/i);
|
|
351
|
+
const action = actionMatch ? actionMatch[1].toLowerCase().replace(/s$/, '') : 'click';
|
|
268
352
|
|
|
269
353
|
const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
|
|
270
354
|
const elementType = elementTypeMatch ? elementTypeMatch[1].toLowerCase() : 'button';
|
|
271
355
|
const elementRole = typeToRole[elementType] || elementType;
|
|
272
356
|
|
|
273
357
|
let actionLocator: string;
|
|
358
|
+
// Resolved from the ROW'S CONTROL, never from the table: the template data below spreads
|
|
359
|
+
// `tableResolved`, so a bare `checkedClass` there would be the TABLE's signal pinned onto the
|
|
360
|
+
// control inside the row (issue #730). The catch path leaves it undefined — with no YAML entry
|
|
361
|
+
// there is no declaration to read, and the emitted call stays byte-identical to before.
|
|
362
|
+
let actionCheckedClass: string | undefined;
|
|
363
|
+
let actionCheckedAttribute: string | undefined;
|
|
274
364
|
try {
|
|
275
365
|
const elementResolved = context.selectorResolver.resolveSelector(
|
|
276
366
|
elementName, context.featureName, elementType, step.nth
|
|
277
367
|
);
|
|
278
368
|
actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
|
|
369
|
+
actionCheckedClass = elementResolved.checkedClass;
|
|
370
|
+
actionCheckedAttribute = elementResolved.checkedAttribute;
|
|
279
371
|
} catch {
|
|
280
372
|
const escapedName = String(elementName)
|
|
281
373
|
.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
@@ -283,9 +375,11 @@ export const tablePatterns: StepPattern[] = [
|
|
|
283
375
|
actionLocator = `tableRow.getByRole('${elementRole}', { name: '${escapedName}' })`;
|
|
284
376
|
}
|
|
285
377
|
|
|
378
|
+
// A declared row SHAPE never matches the header row, so the header offset does not apply.
|
|
379
|
+
const rowIndex = rowIndexExpr(rowToken, context, { headerInRows: !tableResolved.rows });
|
|
286
380
|
return {
|
|
287
381
|
templateName: 'table-action-in-row-nth',
|
|
288
|
-
data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr:
|
|
382
|
+
data: { ...tableResolved, elementName, action, actionLocator, actionCheckedClass, actionCheckedAttribute, rowIndexExpr: rowIndex },
|
|
289
383
|
comment: `${action} ${elementName} in ${tableName} table row ${rowToken}`,
|
|
290
384
|
};
|
|
291
385
|
},
|
|
@@ -293,6 +387,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
293
387
|
},
|
|
294
388
|
{
|
|
295
389
|
name: 'table-action-in-row',
|
|
390
|
+
platforms: ['web'],
|
|
391
|
+
platformAlternative:
|
|
392
|
+
'there is no HTML table on a native screen - tap the control on the row element itself (`When User tap [Row 1 Delete] button`)',
|
|
296
393
|
matcher: (step) => {
|
|
297
394
|
return /\bin\b.*\btable\b/i.test(step.text) &&
|
|
298
395
|
/\bwith\b/i.test(step.text) &&
|
|
@@ -316,10 +413,16 @@ export const tablePatterns: StepPattern[] = [
|
|
|
316
413
|
const tableResolved = context.selectorResolver.resolveSelector(
|
|
317
414
|
tableName, context.featureName, 'table', 0
|
|
318
415
|
);
|
|
416
|
+
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
319
417
|
|
|
320
418
|
// Determine action from step text
|
|
321
|
-
|
|
322
|
-
|
|
419
|
+
// Plural forms are admitted by the matcher above, so they must be READ here too: the old
|
|
420
|
+
// `\b(click|check|uncheck)\b` matched none of "clicks"/"checks"/"unchecks" (the trailing "s"
|
|
421
|
+
// kills the closing boundary), so every plural step silently fell back to 'click' — an
|
|
422
|
+
// `unchecks` step compiled to a click. `uncheck` is listed before `check` so the longer verb
|
|
423
|
+
// wins the alternation.
|
|
424
|
+
const actionMatch = step.text.match(/\b(clicks?|unchecks?|checks?)\b/i);
|
|
425
|
+
const action = actionMatch ? actionMatch[1].toLowerCase().replace(/s$/, '') : 'click';
|
|
323
426
|
|
|
324
427
|
// Extract element type (word after [Act]) and map to ARIA role
|
|
325
428
|
const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
|
|
@@ -332,11 +435,19 @@ export const tablePatterns: StepPattern[] = [
|
|
|
332
435
|
// and when resolution throws we rebuild the historical getByRole string — so the
|
|
333
436
|
// no-entry / unmapped cases stay byte-identical to the previous hardcoded output.
|
|
334
437
|
let actionLocator: string;
|
|
438
|
+
// Resolved from the ROW'S CONTROL, never from the table: the template data below spreads
|
|
439
|
+
// `tableResolved`, so a bare `checkedClass` there would be the TABLE's signal pinned onto the
|
|
440
|
+
// control inside the row (issue #730). The catch path leaves it undefined — with no YAML entry
|
|
441
|
+
// there is no declaration to read, and the emitted call stays byte-identical to before.
|
|
442
|
+
let actionCheckedClass: string | undefined;
|
|
443
|
+
let actionCheckedAttribute: string | undefined;
|
|
335
444
|
try {
|
|
336
445
|
const elementResolved = context.selectorResolver.resolveSelector(
|
|
337
446
|
elementName, context.featureName, elementType, step.nth
|
|
338
447
|
);
|
|
339
448
|
actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
|
|
449
|
+
actionCheckedClass = elementResolved.checkedClass;
|
|
450
|
+
actionCheckedAttribute = elementResolved.checkedAttribute;
|
|
340
451
|
} catch {
|
|
341
452
|
const escapedName = String(elementName)
|
|
342
453
|
.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
@@ -346,7 +457,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
346
457
|
|
|
347
458
|
return {
|
|
348
459
|
templateName: 'table-action-in-row',
|
|
349
|
-
data: { ...tableResolved, elementName, filterValue, action, elementRole, actionLocator },
|
|
460
|
+
data: { ...tableResolved, elementName, filterValue, action, elementRole, actionLocator, actionCheckedClass, actionCheckedAttribute },
|
|
350
461
|
comment: `${action} ${elementName} in ${tableName} table row with ${step.dataRef}`,
|
|
351
462
|
};
|
|
352
463
|
},
|
|
@@ -358,6 +469,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
358
469
|
// Uses filter({ hasText }) per row — resilient to data changes, row ordering, extra rows
|
|
359
470
|
{
|
|
360
471
|
name: 'table-match-data',
|
|
472
|
+
platforms: ['web'],
|
|
473
|
+
platformAlternative:
|
|
474
|
+
'there is no HTML table on a native screen - assert each row element by accessibility-id, or verify the set through @query against the backend',
|
|
361
475
|
matcher: (step) => {
|
|
362
476
|
return step.elementType === 'table' &&
|
|
363
477
|
/\btable\s+match\s+data\b/i.test(step.text) &&
|
|
@@ -369,6 +483,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
369
483
|
);
|
|
370
484
|
|
|
371
485
|
const dataTable = step.dataTable!;
|
|
486
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
372
487
|
|
|
373
488
|
// Resolve {{variable}} references in a cell (headers included — i18n headers are legal)
|
|
374
489
|
const resolveCell = (cell: string): string => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PatternContext, normalizeRefLabel } from '@sun-asterisk/sungen';
|
|
2
|
+
|
|
3
|
+
/** The `columns:` map as it reaches a pattern from a resolved TABLE entry. */
|
|
4
|
+
interface ColumnsCarrier {
|
|
5
|
+
columns?: Record<string, { index?: number; header?: string } | undefined>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The DOM accessible name of a table COLUMN HEADER (issue #674).
|
|
10
|
+
*
|
|
11
|
+
* `see [Category] column in [Article List] table` used to bake the bracket label straight into
|
|
12
|
+
* `getByRole('columnheader', { name: … })`. Only the TABLE went through resolveSelector; the column
|
|
13
|
+
* never did. On a Japanese DOM the header reads 記事分類, so the step could not pass and there was
|
|
14
|
+
* no override — QA's only escape was to rename the Gherkin reference to the Japanese header text.
|
|
15
|
+
* That reference is what ships in the delivered test case, and it has to stay readable in the
|
|
16
|
+
* project's working language: the DOM text is a property of the PAGE, the label is a property of
|
|
17
|
+
* the TEST. They are allowed to differ, so both need a home.
|
|
18
|
+
*
|
|
19
|
+
* Most specific declaration first:
|
|
20
|
+
* 1. the table entry's `columns: { <col>: { header: … } }` — scoped to this one table, and the
|
|
21
|
+
* same map the row-scoped cell steps already index by, so a column is described in one place;
|
|
22
|
+
* 2. a `<col>--columnheader` entry's `name:` — the column key's own selector, which is already
|
|
23
|
+
* how `click [Name] columnheader` and `[Name] columnheader is sorted …` resolve. Reading it
|
|
24
|
+
* here is what makes the whole columnheader surface behave the same way;
|
|
25
|
+
* 3. the bracket label verbatim — the historical behaviour, so an undeclared column compiles
|
|
26
|
+
* byte-identically to before.
|
|
27
|
+
*
|
|
28
|
+
* (2) accepts the entry ONLY when it actually resolved to a columnheader role. resolveSelector
|
|
29
|
+
* falls back to the BASE key when no `--columnheader` key matches, so an unrelated `category`
|
|
30
|
+
* combobox on the same screen would otherwise hand its accessible name to the column step — a
|
|
31
|
+
* silent wrong locator, worse than the bug being fixed. Auto-infer reaches this branch too, but it
|
|
32
|
+
* names the role after the label, so it returns exactly what (3) would.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveColumnHeaderName(
|
|
35
|
+
columnLabel: string,
|
|
36
|
+
context: PatternContext,
|
|
37
|
+
tableResolved?: ColumnsCarrier,
|
|
38
|
+
): string {
|
|
39
|
+
const declared = tableResolved?.columns?.[normalizeRefLabel(columnLabel)]?.header;
|
|
40
|
+
if (declared && declared.trim()) return declared.trim();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
44
|
+
columnLabel, context.featureName, 'column', 0,
|
|
45
|
+
);
|
|
46
|
+
if (resolved.role === 'columnheader' && resolved.name && resolved.name.trim()) {
|
|
47
|
+
return resolved.name.trim();
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// No entry for the column key and auto-infer declined — the label is the header.
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return columnLabel;
|
|
54
|
+
}
|
package/src/utils/row-index.ts
CHANGED
|
@@ -11,9 +11,15 @@ import { PatternContext } from '@sun-asterisk/sungen';
|
|
|
11
11
|
* becomes `Number(testData.get('var'))` via the runtime-data transform, so the offset
|
|
12
12
|
* is emitted as ` + k` / ` - k` around it.
|
|
13
13
|
*/
|
|
14
|
-
export function rowIndexExpr(
|
|
14
|
+
export function rowIndexExpr(
|
|
15
|
+
rowToken: string, context: PatternContext, opts: { headerInRows?: boolean } = {},
|
|
16
|
+
): string {
|
|
15
17
|
const rowBase = context.appProfile?.tableRowBase ?? 1;
|
|
16
|
-
|
|
18
|
+
// `headerInRows: false` — the table declares a row SHAPE (`rows: 'div.job-row'`, issue #688), and a
|
|
19
|
+
// shape never matches the header, so `header_is_row` has nothing to correct for.
|
|
20
|
+
const headerInRows = opts.headerInRows ?? true;
|
|
21
|
+
const headerOffset = headerInRows && !context.appProfile?.tableHeaderIsRow ? 1 : 0;
|
|
22
|
+
const offset = headerOffset - rowBase;
|
|
17
23
|
|
|
18
24
|
const varMatch = rowToken.match(/^\{\{([^}]+)\}\}$/);
|
|
19
25
|
if (!varMatch) return String(Number(rowToken) + offset);
|
|
@@ -19,10 +19,18 @@ export function escapeRegex(text: string): string {
|
|
|
19
19
|
/**
|
|
20
20
|
* Convert a URL path into a RegExp source string, turning dynamic route
|
|
21
21
|
* placeholders (`:name`) into `[^/]+` wildcards while escaping literal segments.
|
|
22
|
-
* `/` is emitted as the escaped `\/` so the result drops straight into `/…/`.
|
|
22
|
+
* `/` is emitted as the escaped `\/` so the result drops straight into `/…/`. A query or
|
|
23
|
+
* fragment in the input is dropped — see the comment in the body.
|
|
23
24
|
*/
|
|
24
25
|
export function pathToRegexSource(path: string): string {
|
|
26
|
+
// Every caller compiles this into a pattern tested against `u.pathname`, which never carries a
|
|
27
|
+
// query or a fragment. A page selector whose `value` includes one ('/search?tab=1' — off the
|
|
28
|
+
// documented "the value carries the PATH only" contract, but written in the field) would
|
|
29
|
+
// otherwise escape the `?` into the pattern and produce a match no real URL can satisfy: an
|
|
30
|
+
// assertion that can never pass, a wait that can only time out. Dropped here, once, rather than
|
|
31
|
+
// in each of the four resolvers that build a pathname pattern.
|
|
25
32
|
return path
|
|
33
|
+
.replace(/[?#].*$/, '')
|
|
26
34
|
.split('/')
|
|
27
35
|
.map((segment) =>
|
|
28
36
|
/^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ? '[^/]+' : escapeRegex(segment)
|