@sungen/driver-ui 3.2.29 → 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 +18 -2
- package/src/patterns/form-patterns.ts +19 -4
- package/src/patterns/interaction-patterns.ts +103 -5
- package/src/patterns/table-pagination-patterns.ts +102 -0
- package/src/patterns/table-patterns.ts +38 -7
- package/src/utils/column-header.ts +54 -0
- 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
|
|
@@ -102,10 +103,15 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
102
103
|
// Create a safe variable name from column name
|
|
103
104
|
const columnIndexVar = `colIndex${step.selectorRef!.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
104
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
|
+
|
|
105
111
|
return {
|
|
106
112
|
templateName: 'column-cell-assertion',
|
|
107
113
|
data: {
|
|
108
|
-
columnName
|
|
114
|
+
columnName,
|
|
109
115
|
rowNth,
|
|
110
116
|
columnIndexVar,
|
|
111
117
|
dataValue,
|
|
@@ -360,16 +366,26 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
360
366
|
// aria-selected — their state is `checked`; other targets probe at runtime.
|
|
361
367
|
let stateAssertion = state.assertion;
|
|
362
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';
|
|
363
375
|
if (selectedProbe && resolved.role && ['checkbox', 'radio', 'switch'].includes(resolved.role)) {
|
|
364
376
|
stateAssertion = 'toBeChecked()';
|
|
365
377
|
selectedProbe = false;
|
|
378
|
+
isCheckedAssertion = true;
|
|
379
|
+
checkedWant = true;
|
|
366
380
|
}
|
|
367
|
-
|
|
368
381
|
const code = context.templateEngine.renderStep('state-with-filter-assertion', {
|
|
369
382
|
...resolved,
|
|
383
|
+
selectorRef: step.selectorRef,
|
|
370
384
|
dataValue,
|
|
371
385
|
stateAssertion,
|
|
372
386
|
selectedProbe,
|
|
387
|
+
isCheckedAssertion,
|
|
388
|
+
checkedWant,
|
|
373
389
|
});
|
|
374
390
|
return { code, comment: `Assert ${step.selectorRef} with ${step.dataRef} is ${state.label}` };
|
|
375
391
|
},
|
|
@@ -100,8 +100,15 @@ export const formPatterns: StepPattern[] = [
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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.
|
|
105
112
|
const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
|
|
106
113
|
const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
|
|
107
114
|
|
|
@@ -117,8 +124,13 @@ export const formPatterns: StepPattern[] = [
|
|
|
117
124
|
},
|
|
118
125
|
{
|
|
119
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.
|
|
120
132
|
matcher: (step: ParsedStep) =>
|
|
121
|
-
(step.text
|
|
133
|
+
/\bchecks?\b/.test(step.text) && !!step.selectorRef,
|
|
122
134
|
resolver: (step, context) => {
|
|
123
135
|
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
124
136
|
return {
|
|
@@ -131,8 +143,11 @@ export const formPatterns: StepPattern[] = [
|
|
|
131
143
|
},
|
|
132
144
|
{
|
|
133
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.
|
|
134
149
|
matcher: (step: ParsedStep) =>
|
|
135
|
-
(step.text
|
|
150
|
+
/\bunchecks?\b/.test(step.text) && !!step.selectorRef,
|
|
136
151
|
resolver: (step, context) => {
|
|
137
152
|
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
|
|
138
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
|
|
@@ -231,6 +279,52 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
231
279
|
},
|
|
232
280
|
priority: 14, // Below click-element-with-text (15) so known selectors use proper locator
|
|
233
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
|
+
},
|
|
234
328
|
{
|
|
235
329
|
name: 'click-element-with-text',
|
|
236
330
|
matcher: (step: ParsedStep) =>
|
|
@@ -470,19 +564,23 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
470
564
|
|
|
471
565
|
const isAbsoluteUrl = /^https?:\/\//.test(path);
|
|
472
566
|
let pathRegex: string;
|
|
567
|
+
let hostRegex: string | undefined;
|
|
473
568
|
if (isAbsoluteUrl) {
|
|
474
569
|
const url = new URL(path);
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
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);
|
|
479
577
|
} else {
|
|
480
578
|
pathRegex = pathToRegexSource(path);
|
|
481
579
|
}
|
|
482
580
|
|
|
483
581
|
return {
|
|
484
582
|
templateName: 'wait-for-page',
|
|
485
|
-
data: { pathRegex },
|
|
583
|
+
data: { pathRegex, hostRegex },
|
|
486
584
|
comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
|
|
487
585
|
};
|
|
488
586
|
},
|
|
@@ -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,7 @@
|
|
|
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';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Row LABEL resolution — `[First Contact Row] row in [T] table` (cb59e157, SG-W010).
|
|
@@ -145,10 +146,14 @@ export const tablePatterns: StepPattern[] = [
|
|
|
145
146
|
tableName, context.featureName, 'table', step.nth
|
|
146
147
|
);
|
|
147
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
|
+
|
|
148
153
|
const isGiven = isGivenContext(context);
|
|
149
154
|
return {
|
|
150
155
|
templateName: 'table-column-exists',
|
|
151
|
-
data: { ...resolved, columnName, isGiven },
|
|
156
|
+
data: { ...resolved, columnName: headerName, isGiven },
|
|
152
157
|
comment: `${isGiven ? 'Wait' : 'Assert'} ${columnName} column in ${tableName} table`,
|
|
153
158
|
};
|
|
154
159
|
},
|
|
@@ -337,19 +342,32 @@ export const tablePatterns: StepPattern[] = [
|
|
|
337
342
|
);
|
|
338
343
|
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
339
344
|
|
|
340
|
-
|
|
341
|
-
|
|
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';
|
|
342
352
|
|
|
343
353
|
const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
|
|
344
354
|
const elementType = elementTypeMatch ? elementTypeMatch[1].toLowerCase() : 'button';
|
|
345
355
|
const elementRole = typeToRole[elementType] || elementType;
|
|
346
356
|
|
|
347
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;
|
|
348
364
|
try {
|
|
349
365
|
const elementResolved = context.selectorResolver.resolveSelector(
|
|
350
366
|
elementName, context.featureName, elementType, step.nth
|
|
351
367
|
);
|
|
352
368
|
actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
|
|
369
|
+
actionCheckedClass = elementResolved.checkedClass;
|
|
370
|
+
actionCheckedAttribute = elementResolved.checkedAttribute;
|
|
353
371
|
} catch {
|
|
354
372
|
const escapedName = String(elementName)
|
|
355
373
|
.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
@@ -361,7 +379,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
361
379
|
const rowIndex = rowIndexExpr(rowToken, context, { headerInRows: !tableResolved.rows });
|
|
362
380
|
return {
|
|
363
381
|
templateName: 'table-action-in-row-nth',
|
|
364
|
-
data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr: rowIndex },
|
|
382
|
+
data: { ...tableResolved, elementName, action, actionLocator, actionCheckedClass, actionCheckedAttribute, rowIndexExpr: rowIndex },
|
|
365
383
|
comment: `${action} ${elementName} in ${tableName} table row ${rowToken}`,
|
|
366
384
|
};
|
|
367
385
|
},
|
|
@@ -398,8 +416,13 @@ export const tablePatterns: StepPattern[] = [
|
|
|
398
416
|
warnIfRowsLikelyMissing(tableResolved, tableName, context, step.text);
|
|
399
417
|
|
|
400
418
|
// Determine action from step text
|
|
401
|
-
|
|
402
|
-
|
|
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';
|
|
403
426
|
|
|
404
427
|
// Extract element type (word after [Act]) and map to ARIA role
|
|
405
428
|
const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
|
|
@@ -412,11 +435,19 @@ export const tablePatterns: StepPattern[] = [
|
|
|
412
435
|
// and when resolution throws we rebuild the historical getByRole string — so the
|
|
413
436
|
// no-entry / unmapped cases stay byte-identical to the previous hardcoded output.
|
|
414
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;
|
|
415
444
|
try {
|
|
416
445
|
const elementResolved = context.selectorResolver.resolveSelector(
|
|
417
446
|
elementName, context.featureName, elementType, step.nth
|
|
418
447
|
);
|
|
419
448
|
actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
|
|
449
|
+
actionCheckedClass = elementResolved.checkedClass;
|
|
450
|
+
actionCheckedAttribute = elementResolved.checkedAttribute;
|
|
420
451
|
} catch {
|
|
421
452
|
const escapedName = String(elementName)
|
|
422
453
|
.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
@@ -426,7 +457,7 @@ export const tablePatterns: StepPattern[] = [
|
|
|
426
457
|
|
|
427
458
|
return {
|
|
428
459
|
templateName: 'table-action-in-row',
|
|
429
|
-
data: { ...tableResolved, elementName, filterValue, action, elementRole, actionLocator },
|
|
460
|
+
data: { ...tableResolved, elementName, filterValue, action, elementRole, actionLocator, actionCheckedClass, actionCheckedAttribute },
|
|
430
461
|
comment: `${action} ${elementName} in ${tableName} table row with ${step.dataRef}`,
|
|
431
462
|
};
|
|
432
463
|
},
|
|
@@ -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
|
+
}
|
|
@@ -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)
|