@sungen/driver-ui 3.2.27 → 3.2.29
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/patterns/assertion-patterns.ts +59 -13
- package/src/patterns/capture-patterns.ts +3 -0
- package/src/patterns/focus-patterns.ts +3 -0
- package/src/patterns/form-patterns.ts +3 -0
- package/src/patterns/interaction-patterns.ts +13 -0
- package/src/patterns/navigation-patterns.ts +3 -0
- package/src/patterns/storage-patterns.ts +6 -0
- package/src/patterns/table-patterns.ts +101 -17
- package/src/utils/row-index.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.29",
|
|
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.29"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@playwright/test": "^1.57.0"
|
|
@@ -30,6 +30,19 @@ function qualifiedState(text: string): { assertion: string; label: string; probe
|
|
|
30
30
|
* locator strict-mode-fails. The role-strategy counterpart is LIST_MEMBER_ROLES. */
|
|
31
31
|
const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
|
|
32
32
|
|
|
33
|
+
/** Resolved strategies that pin a node by IDENTITY rather than by the text it renders. A
|
|
34
|
+
* `see [X] label with {{v}}` step honours these as real locators and asserts their text (#666).
|
|
35
|
+
*
|
|
36
|
+
* The omissions are deliberate, not an oversight: `text` finds the node by the very caption the
|
|
37
|
+
* label pattern is about, so honouring it would assert the caption contains the value; `label`
|
|
38
|
+
* (getByLabel) and `placeholder` resolve the labelled CONTROL, whose content is a `value`, not
|
|
39
|
+
* text; `role` names an element through its accessible name, which for a caption IS the caption.
|
|
40
|
+
* Each of those keeps the caption+value text reading. */
|
|
41
|
+
const IDENTITY_STRATEGIES = new Set([
|
|
42
|
+
'testid', 'id', 'css', 'locator', 'xpath',
|
|
43
|
+
'accessibility-id', 'android-uiautomator', 'ios-predicate',
|
|
44
|
+
]);
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
|
|
35
48
|
* MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
|
|
@@ -68,6 +81,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
68
81
|
// Column cell assertion: "see [Department] column 1 with {{value}}" -> check table cell text
|
|
69
82
|
{
|
|
70
83
|
name: 'column-cell-assertion',
|
|
84
|
+
platforms: ['web'],
|
|
85
|
+
platformAlternative:
|
|
86
|
+
'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
87
|
matcher: (step: ParsedStep) =>
|
|
72
88
|
(step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
|
|
73
89
|
!!step.selectorRef &&
|
|
@@ -112,6 +128,10 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
112
128
|
// `step.featurePath` is never populated by the parser, so it always degraded to '/'.
|
|
113
129
|
const fallbackPath = context.featurePath || '/';
|
|
114
130
|
let path = fallbackPath;
|
|
131
|
+
// The page's declared `anchor:` — the element that proves the page rendered. Without it the
|
|
132
|
+
// step is a URL-only oracle, which a client-side router can satisfy for a few hundred
|
|
133
|
+
// milliseconds before a guard bounces the user away (#662).
|
|
134
|
+
let anchor: Record<string, unknown> | undefined;
|
|
115
135
|
|
|
116
136
|
// If selector is present, extract path from selector's value attribute
|
|
117
137
|
if (step.selectorRef) {
|
|
@@ -128,6 +148,7 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
128
148
|
// URL. Guard on the entry kind: use `value` only for a page selector, else fall back to
|
|
129
149
|
// the feature path rather than compiling `toHaveURL(/button/)`.
|
|
130
150
|
path = resolved.selectorType === 'page' ? (resolved.value || fallbackPath) : fallbackPath;
|
|
151
|
+
if (resolved.selectorType === 'page' && resolved.anchor) anchor = { ...resolved.anchor };
|
|
131
152
|
} catch (error) {
|
|
132
153
|
// Fallback to feature path if selector not found
|
|
133
154
|
path = fallbackPath;
|
|
@@ -153,7 +174,7 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
153
174
|
templateName: 'page-assertion',
|
|
154
175
|
// `dataRef` rides along so a runtime failure can name the test-data key an author must go
|
|
155
176
|
// fix — an empty/absent value is otherwise the hardest kind of red to trace.
|
|
156
|
-
data: { pathRegex, query, dataRef: step.dataRef || '' },
|
|
177
|
+
data: { pathRegex, query, dataRef: step.dataRef || '', anchor },
|
|
157
178
|
comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
|
|
158
179
|
};
|
|
159
180
|
},
|
|
@@ -461,21 +482,40 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
461
482
|
|
|
462
483
|
// --- Label-value pattern: "User see [X] label with {{Y}}" ---
|
|
463
484
|
if (step.elementType === 'label' && step.dataRef) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
485
|
+
// A `label` ref reads two different ways, and the selectors.yaml entry decides which.
|
|
486
|
+
//
|
|
487
|
+
// NO ENTRY, or an entry that finds its element BY TEXT — the ref names on-screen
|
|
488
|
+
// CAPTION text rather than an element, so the assertion is "the screen shows
|
|
489
|
+
// `X <value>`". That is what label-value-assertion renders, and it stays the default.
|
|
490
|
+
//
|
|
491
|
+
// AN ENTRY NAMING A CONCRETE NODE (xpath / accessibility-id / testid / id / css / …) —
|
|
492
|
+
// the author has said WHERE the value lives. That locator used to be resolved and then
|
|
493
|
+
// thrown away, leaving a page-wide text match: on Android `UiSelector().text(v)`, which
|
|
494
|
+
// reads only @text, so a Flutter/RN node exposing @content-desc never matched. The
|
|
495
|
+
// entry was correct and the step failed every time (#666). Honouring it puts `label`
|
|
496
|
+
// alongside `header`, which never had the special case.
|
|
497
|
+
//
|
|
498
|
+
// CONTAINS, not exact: an entry for a label/value pair points legitimately either at
|
|
499
|
+
// the value node ("JD-001") or at the row holding both ("Job No: JD-001"), and the web
|
|
500
|
+
// regex it replaces was itself anchored only at the end. `exact: true` opts into the
|
|
501
|
+
// strict comparison.
|
|
502
|
+
//
|
|
503
|
+
// `resolved` is the SAME lookup, already performed above — this branch used to repeat it.
|
|
504
|
+
if (IDENTITY_STRATEGIES.has(resolved.strategy)) {
|
|
505
|
+
const code = context.templateEngine.renderStep(
|
|
506
|
+
resolved.exact ? 'have-text-assertion' : 'contain-text-assertion',
|
|
507
|
+
{ ...resolved, expectedText: dataValue },
|
|
471
508
|
);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// No selector entry — use label from selectorRef
|
|
509
|
+
return {
|
|
510
|
+
code,
|
|
511
|
+
comment: `Assert ${step.selectorRef} label ${resolved.exact ? 'has' : 'contains'} value ${step.dataRef}`,
|
|
512
|
+
};
|
|
477
513
|
}
|
|
478
514
|
|
|
515
|
+
// `value: ''` is the declared opt-out: drop the caption prefix, match the value alone.
|
|
516
|
+
const label = resolved.value !== undefined && resolved.value.trim() === ''
|
|
517
|
+
? undefined
|
|
518
|
+
: step.selectorRef;
|
|
479
519
|
const code = context.templateEngine.renderStep('label-value-assertion', {
|
|
480
520
|
label,
|
|
481
521
|
dataValue,
|
|
@@ -917,6 +957,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
917
957
|
},
|
|
918
958
|
{
|
|
919
959
|
name: 'is-sorted-ascending',
|
|
960
|
+
platforms: ['web'],
|
|
961
|
+
platformAlternative:
|
|
962
|
+
'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
|
|
920
963
|
states: ['sorted ascending'],
|
|
921
964
|
matcher: (step: ParsedStep) =>
|
|
922
965
|
/\b(see|sees)\s+\[/.test(step.text) && /\bsorted ascending\b/.test(step.text) && !!step.selectorRef,
|
|
@@ -932,6 +975,9 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
932
975
|
},
|
|
933
976
|
{
|
|
934
977
|
name: 'is-sorted-descending',
|
|
978
|
+
platforms: ['web'],
|
|
979
|
+
platformAlternative:
|
|
980
|
+
'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
|
|
935
981
|
states: ['sorted descending'],
|
|
936
982
|
matcher: (step: ParsedStep) =>
|
|
937
983
|
/\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' &&
|
|
@@ -106,6 +106,9 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
name: 'alert-fill',
|
|
109
|
+
platforms: ['web'],
|
|
110
|
+
platformAlternative:
|
|
111
|
+
'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
112
|
matcher: (step: ParsedStep) =>
|
|
110
113
|
step.elementType === 'alert' &&
|
|
111
114
|
step.text.includes('fill') &&
|
|
@@ -295,6 +298,12 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
295
298
|
// click-element-with-text. Without this, hover fell through to hover-element which IGNORED
|
|
296
299
|
// the value and hovered the first match (strict-mode / wrong element on dynamic lists).
|
|
297
300
|
name: 'hover-element-with-text',
|
|
301
|
+
// Pure: this resolver requests `hover-element-with-text` and nothing else, which is what makes
|
|
302
|
+
// the declaration safe. A pattern that can reach a shared template (click, recipe-sequence) must
|
|
303
|
+
// NOT be declared — see the mixed `unknown-element-action` below, which also serves click.
|
|
304
|
+
platforms: ['web'],
|
|
305
|
+
platformAlternative:
|
|
306
|
+
'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
307
|
matcher: (step: ParsedStep) =>
|
|
299
308
|
(step.text.includes('hovers') || step.text.match(/\bhover\b/))
|
|
300
309
|
&& !!step.selectorRef
|
|
@@ -328,6 +337,10 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
328
337
|
},
|
|
329
338
|
{
|
|
330
339
|
name: 'hover-element',
|
|
340
|
+
// Pure: requests `hover-action` only.
|
|
341
|
+
platforms: ['web'],
|
|
342
|
+
platformAlternative:
|
|
343
|
+
'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
344
|
matcher: (step: ParsedStep) =>
|
|
332
345
|
(step.text.includes('hovers') || step.text.match(/\bhover\b/)) && !!step.selectorRef,
|
|
333
346
|
resolver: (step, context) => {
|
|
@@ -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);
|
|
@@ -18,6 +18,62 @@
|
|
|
18
18
|
import { StepPattern, PatternContext } from '@sun-asterisk/sungen';
|
|
19
19
|
import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Row LABEL resolution — `[First Contact Row] row in [T] table` (cb59e157, SG-W010).
|
|
23
|
+
*
|
|
24
|
+
* A `type: locator` entry for the row label positions ONE row (`tbody tr:first-child`, relative to
|
|
25
|
+
* the table) and the step asserts that exact row. No entry → the label is display-only and the
|
|
26
|
+
* historical filter-by-value query runs, byte-identical. Shared by the exists/hidden twins so the
|
|
27
|
+
* two can never drift apart again — `is hidden` used to compute the label and use it only in the
|
|
28
|
+
* comment (issue #688).
|
|
29
|
+
*
|
|
30
|
+
* NOT the row SHAPE. `div.job-row` (what every row matches) is a property of the table and lives on
|
|
31
|
+
* the table entry as `rows:`; put through this branch it strict-mode-fails, which is what the
|
|
32
|
+
* runtime `toHaveCount(1)` guard in the positioned templates now says out loud.
|
|
33
|
+
*/
|
|
34
|
+
function resolveRowLabel(rowLabel: string, tableName: string, context: PatternContext): string | undefined {
|
|
35
|
+
if (!rowLabel || rowLabel === tableName) return undefined;
|
|
36
|
+
try {
|
|
37
|
+
const rowResolved = context.selectorResolver.resolveSelector(rowLabel, context.featureName, 'row', 0);
|
|
38
|
+
if (rowResolved.strategy === 'locator' && rowResolved.value && rowResolved.value.trim()) {
|
|
39
|
+
return rowResolved.value;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// no selectors.yaml entry for the row label — display-only label (fallback)
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* SG-W016 — a table located by CSS that declares no `rows:` (issue #688).
|
|
49
|
+
*
|
|
50
|
+
* Every table template assumes an ARIA table: rows are `getByRole('row')`. A list built from plain
|
|
51
|
+
* <div>s has no role=row, so that universe is 0 elements — `is hidden` and `is empty` then pass
|
|
52
|
+
* without checking anything, counts and clicks time out. The compiler cannot see the DOM, so this is
|
|
53
|
+
* a heuristic and a WARNING: a CSS/testid-located table whose value does not even mention `table`
|
|
54
|
+
* is the shape where the assumption most often fails. `role: table`, `table.users`, or a declared
|
|
55
|
+
* `rows:` are all evidence the author has thought about it, and stay silent. Once per table per
|
|
56
|
+
* feature — the same table is named by every row step of a scenario.
|
|
57
|
+
*/
|
|
58
|
+
const warnedTables = new WeakMap<object, Set<string>>();
|
|
59
|
+
function warnIfRowsLikelyMissing(
|
|
60
|
+
resolved: { strategy?: string; value?: string; rows?: string }, tableName: string,
|
|
61
|
+
context: PatternContext, stepText: string,
|
|
62
|
+
): void {
|
|
63
|
+
const sink = context.diagnostics;
|
|
64
|
+
if (!sink || resolved.rows) return;
|
|
65
|
+
if (!['css', 'locator', 'testid', 'id'].includes(String(resolved.strategy))) return;
|
|
66
|
+
if (/\btable\b/i.test(String(resolved.value ?? ''))) return;
|
|
67
|
+
const key = `${context.featureName ?? ''}::${tableName}`;
|
|
68
|
+
let seen = warnedTables.get(sink);
|
|
69
|
+
if (!seen) { seen = new Set(); warnedTables.set(sink, seen); }
|
|
70
|
+
if (seen.has(key)) return;
|
|
71
|
+
seen.add(key);
|
|
72
|
+
sink.add('SG-W016',
|
|
73
|
+
`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.`,
|
|
74
|
+
{ feature: context.featureName, step: stepText });
|
|
75
|
+
}
|
|
76
|
+
|
|
21
77
|
/** Helper: check if current step is in a Given context */
|
|
22
78
|
function isGivenContext(context: PatternContext): boolean {
|
|
23
79
|
return context.effectiveKeyword === 'Given';
|
|
@@ -38,6 +94,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
38
94
|
// "User see [Table] table with {{count}}" — row count (no "rows" keyword needed)
|
|
39
95
|
{
|
|
40
96
|
name: 'table-row-count',
|
|
97
|
+
platforms: ['web'],
|
|
98
|
+
platformAlternative:
|
|
99
|
+
'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
100
|
matcher: (step) => {
|
|
42
101
|
return step.elementType === 'table' &&
|
|
43
102
|
/\btable\s+with\b/i.test(step.text) &&
|
|
@@ -51,6 +110,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
51
110
|
step.selectorRef!, context.featureName, 'table', step.nth
|
|
52
111
|
);
|
|
53
112
|
const count = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
113
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
54
114
|
|
|
55
115
|
const isGiven = isGivenContext(context);
|
|
56
116
|
return {
|
|
@@ -65,6 +125,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
65
125
|
// "User see [Col] column in [Table] table"
|
|
66
126
|
{
|
|
67
127
|
name: 'table-column-exists',
|
|
128
|
+
platforms: ['web'],
|
|
129
|
+
platformAlternative:
|
|
130
|
+
'there is no HTML table on a native screen - assert the header element by accessibility-id instead',
|
|
68
131
|
matcher: (step) => {
|
|
69
132
|
return /\bcolumn\s+in\b/i.test(step.text) &&
|
|
70
133
|
/\btable\b/i.test(step.text) &&
|
|
@@ -95,6 +158,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
95
158
|
// "User see [Table] table is empty"
|
|
96
159
|
{
|
|
97
160
|
name: 'table-is-empty',
|
|
161
|
+
platforms: ['web'],
|
|
162
|
+
platformAlternative:
|
|
163
|
+
'there is no HTML table on a native screen - assert the empty-state element is visible and a row marker `is hidden`',
|
|
98
164
|
states: ['empty'],
|
|
99
165
|
matcher: (step) => {
|
|
100
166
|
return /\btable\s+is\s+empty\b/i.test(step.text);
|
|
@@ -104,6 +170,8 @@ export const tablePatterns: StepPattern[] = [
|
|
|
104
170
|
step.selectorRef!, context.featureName, 'table', step.nth
|
|
105
171
|
);
|
|
106
172
|
|
|
173
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
174
|
+
|
|
107
175
|
const isGiven = isGivenContext(context);
|
|
108
176
|
return {
|
|
109
177
|
templateName: 'table-empty',
|
|
@@ -151,6 +219,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
151
219
|
// "User see [Ref] row in [Table] table with {{value}}" — row exists (enters row scope)
|
|
152
220
|
{
|
|
153
221
|
name: 'table-row-exists',
|
|
222
|
+
platforms: ['web'],
|
|
223
|
+
platformAlternative:
|
|
224
|
+
'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
225
|
matcher: (step) => {
|
|
155
226
|
return step.elementType === 'row' &&
|
|
156
227
|
/\bin\b.*\btable\b/i.test(step.text) &&
|
|
@@ -179,24 +250,13 @@ export const tablePatterns: StepPattern[] = [
|
|
|
179
250
|
// (RELATIVE to the table, e.g. `tbody tr:first-child`) positions the row,
|
|
180
251
|
// and the assertion becomes toContainText on that exact row. No entry →
|
|
181
252
|
// 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
|
-
}
|
|
253
|
+
const rowSelector = resolveRowLabel(rowLabel, tableName, context);
|
|
254
|
+
if (!rowSelector) warnIfRowsLikelyMissing(resolved, tableName, context, step.text);
|
|
195
255
|
|
|
196
256
|
const isGiven = isGivenContext(context);
|
|
197
257
|
return {
|
|
198
258
|
templateName: 'table-row-exists',
|
|
199
|
-
data: { ...resolved, filterValue, isGiven, rowSelector },
|
|
259
|
+
data: { ...resolved, filterValue, isGiven, rowSelector, rowLabel },
|
|
200
260
|
comment: rowSelector
|
|
201
261
|
? `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} (positioned row) in ${tableName} table contains ${step.dataRef}`
|
|
202
262
|
: `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table with ${step.dataRef}`,
|
|
@@ -208,6 +268,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
208
268
|
// "User see [Ref] row in [Table] table with {{value}} is hidden"
|
|
209
269
|
{
|
|
210
270
|
name: 'table-row-hidden',
|
|
271
|
+
platforms: ['web'],
|
|
272
|
+
platformAlternative:
|
|
273
|
+
'there is no HTML table on a native screen - assert the row element `is hidden` by accessibility-id',
|
|
211
274
|
states: ['hidden'],
|
|
212
275
|
matcher: (step) => {
|
|
213
276
|
return step.elementType === 'row' &&
|
|
@@ -229,11 +292,18 @@ export const tablePatterns: StepPattern[] = [
|
|
|
229
292
|
);
|
|
230
293
|
const filterValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
|
|
231
294
|
|
|
295
|
+
// Same label contract as the exists twin (issue #688): a declared positioned row is asserted
|
|
296
|
+
// NOT to hold the value; an undeclared label keeps the filter-by-value count-0 query.
|
|
297
|
+
const rowSelector = resolveRowLabel(rowLabel, tableName, context);
|
|
298
|
+
if (!rowSelector) warnIfRowsLikelyMissing(resolved, tableName, context, step.text);
|
|
299
|
+
|
|
232
300
|
const isGiven = isGivenContext(context);
|
|
233
301
|
return {
|
|
234
302
|
templateName: 'table-row-not-exists',
|
|
235
|
-
data: { ...resolved, filterValue, isGiven },
|
|
236
|
-
comment:
|
|
303
|
+
data: { ...resolved, filterValue, isGiven, rowSelector, rowLabel },
|
|
304
|
+
comment: rowSelector
|
|
305
|
+
? `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} (positioned row) in ${tableName} table does not contain ${step.dataRef}`
|
|
306
|
+
: `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table is hidden`,
|
|
237
307
|
};
|
|
238
308
|
},
|
|
239
309
|
priority: 19,
|
|
@@ -247,6 +317,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
247
317
|
// where a re-render between queries hits two different rows. Row numbering follows
|
|
248
318
|
// qa/app.yaml conventions.table (row_base, header_is_row).
|
|
249
319
|
name: 'table-action-in-row-nth',
|
|
320
|
+
platforms: ['web'],
|
|
321
|
+
platformAlternative:
|
|
322
|
+
'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
323
|
matcher: (step) => {
|
|
251
324
|
return TABLE_ROW_NTH.test(step.text) &&
|
|
252
325
|
/\b(click|clicks|check|checks|uncheck|unchecks)\b/i.test(step.text) &&
|
|
@@ -262,6 +335,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
262
335
|
const tableResolved = context.selectorResolver.resolveSelector(
|
|
263
336
|
tableName, context.featureName, 'table', 0
|
|
264
337
|
);
|
|
338
|
+
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
265
339
|
|
|
266
340
|
const actionMatch = step.text.match(/\b(click|check|uncheck)\b/i);
|
|
267
341
|
const action = actionMatch ? actionMatch[1].toLowerCase() : 'click';
|
|
@@ -283,9 +357,11 @@ export const tablePatterns: StepPattern[] = [
|
|
|
283
357
|
actionLocator = `tableRow.getByRole('${elementRole}', { name: '${escapedName}' })`;
|
|
284
358
|
}
|
|
285
359
|
|
|
360
|
+
// A declared row SHAPE never matches the header row, so the header offset does not apply.
|
|
361
|
+
const rowIndex = rowIndexExpr(rowToken, context, { headerInRows: !tableResolved.rows });
|
|
286
362
|
return {
|
|
287
363
|
templateName: 'table-action-in-row-nth',
|
|
288
|
-
data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr:
|
|
364
|
+
data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr: rowIndex },
|
|
289
365
|
comment: `${action} ${elementName} in ${tableName} table row ${rowToken}`,
|
|
290
366
|
};
|
|
291
367
|
},
|
|
@@ -293,6 +369,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
293
369
|
},
|
|
294
370
|
{
|
|
295
371
|
name: 'table-action-in-row',
|
|
372
|
+
platforms: ['web'],
|
|
373
|
+
platformAlternative:
|
|
374
|
+
'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
375
|
matcher: (step) => {
|
|
297
376
|
return /\bin\b.*\btable\b/i.test(step.text) &&
|
|
298
377
|
/\bwith\b/i.test(step.text) &&
|
|
@@ -316,6 +395,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
316
395
|
const tableResolved = context.selectorResolver.resolveSelector(
|
|
317
396
|
tableName, context.featureName, 'table', 0
|
|
318
397
|
);
|
|
398
|
+
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
319
399
|
|
|
320
400
|
// Determine action from step text
|
|
321
401
|
const actionMatch = step.text.match(/\b(click|check|uncheck)\b/i);
|
|
@@ -358,6 +438,9 @@ export const tablePatterns: StepPattern[] = [
|
|
|
358
438
|
// Uses filter({ hasText }) per row — resilient to data changes, row ordering, extra rows
|
|
359
439
|
{
|
|
360
440
|
name: 'table-match-data',
|
|
441
|
+
platforms: ['web'],
|
|
442
|
+
platformAlternative:
|
|
443
|
+
'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
444
|
matcher: (step) => {
|
|
362
445
|
return step.elementType === 'table' &&
|
|
363
446
|
/\btable\s+match\s+data\b/i.test(step.text) &&
|
|
@@ -369,6 +452,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
369
452
|
);
|
|
370
453
|
|
|
371
454
|
const dataTable = step.dataTable!;
|
|
455
|
+
warnIfRowsLikelyMissing(resolved, step.selectorRef!, context, step.text);
|
|
372
456
|
|
|
373
457
|
// Resolve {{variable}} references in a cell (headers included — i18n headers are legal)
|
|
374
458
|
const resolveCell = (cell: string): string => {
|
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);
|