@sungen/driver-ui 3.2.8 → 3.2.10-beta.1
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/app-profile-presets.ts +74 -0
- package/src/index.ts +2 -0
- package/src/patterns/capture-patterns.ts +39 -0
- package/src/patterns/form-patterns.ts +46 -1
- package/src/patterns/interaction-patterns.ts +56 -2
- package/src/patterns/navigation-patterns.ts +21 -5
- package/src/patterns/table-patterns.ts +52 -0
- package/src/utils/row-index.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.10-beta.1",
|
|
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.10-beta.1"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@playwright/test": "^1.57.0"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-profile presets shipped with the UI driver (spec: sungen_app_profile_spec.md §3).
|
|
3
|
+
* A preset is itself an AppProfile-shaped object that a project's `qa/app.yaml` pulls in
|
|
4
|
+
* via `extends: <name>` (or `extends: "@sungen/app-<name>"`). The project file always
|
|
5
|
+
* wins over the preset; per-element `variant:`/`scope:` in selectors.yaml wins over both.
|
|
6
|
+
*
|
|
7
|
+
* Keep presets truthful and minimal — every key must be consumed by a compiler slice
|
|
8
|
+
* (portal_roles → dialog-scope exemption; feedback.toast → name policy; widgets → the
|
|
9
|
+
* P3 recipe engine reads these when it lands).
|
|
10
|
+
*/
|
|
11
|
+
export const APP_PROFILE_PRESETS: Record<string, unknown> = {
|
|
12
|
+
// Reserved base layer — ALWAYS merged first by the loader (not listed in extends).
|
|
13
|
+
// Ships the built-in widget recipes so a per-element `variant: radix-select` works
|
|
14
|
+
// even in a project with no qa/app.yaml at all.
|
|
15
|
+
builtin: {
|
|
16
|
+
recipes: {
|
|
17
|
+
// Radix UI Select (incl. shadcn/ui): options portal to body with proper ARIA roles.
|
|
18
|
+
'radix-select': {
|
|
19
|
+
select: [
|
|
20
|
+
{ click: '$trigger' },
|
|
21
|
+
{ clickText: { role: 'option', text: '$value', exact: true } },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
// Ant Design Select: closed panels stay in the DOM hidden — scope every lookup
|
|
25
|
+
// to the VISIBLE dropdown panel. `waitFor: attached` first because a select that
|
|
26
|
+
// already holds a value collapses its input to ~0px (the chip covers the click
|
|
27
|
+
// target). `clickText` scrolls the target option into view before clicking, which
|
|
28
|
+
// nudges a lazily-rendered / below-the-fold option list.
|
|
29
|
+
'antd-select': {
|
|
30
|
+
select: [
|
|
31
|
+
{ waitFor: { target: '$trigger', state: 'attached' } },
|
|
32
|
+
{ click: '$trigger' },
|
|
33
|
+
{ waitFor: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)' },
|
|
34
|
+
{ clickText: { within: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)', text: '$value' } },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
// Ant Design DatePicker (typed input + panel): after typing, the panel keeps
|
|
38
|
+
// blocking pointer events; a single blur dismisses it (Escape/force-click are
|
|
39
|
+
// unreliable). Enter commits the typed value first.
|
|
40
|
+
'antd-datepicker': {
|
|
41
|
+
fill: [
|
|
42
|
+
{ click: '$trigger' },
|
|
43
|
+
{ type: '$value' },
|
|
44
|
+
{ press: 'Enter' },
|
|
45
|
+
{ blur: '$trigger' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Classic server-rendered / native-widget apps: no portals anywhere, native <select>.
|
|
52
|
+
native: {
|
|
53
|
+
app: { ui_library: 'native' },
|
|
54
|
+
layers: { portal_roles: [] },
|
|
55
|
+
widgets: { select: 'native' },
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Radix UI (incl. shadcn/ui): Select/Menu/Tooltip render through portals onto
|
|
59
|
+
// document.body; Toast has no accessible label by default (name derives from content).
|
|
60
|
+
radix: {
|
|
61
|
+
app: { ui_library: 'radix' },
|
|
62
|
+
layers: { portal_roles: ['option', 'listbox', 'menu', 'menuitem', 'tooltip'] },
|
|
63
|
+
feedback: { toast: { role: 'status', named_by: 'content' } },
|
|
64
|
+
widgets: { select: 'radix-select' },
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// Ant Design: dropdown/picker panels portal to document.body (and closed panels stay
|
|
68
|
+
// in the DOM hidden — recipes must scope to the VISIBLE panel, handled in P3).
|
|
69
|
+
antd: {
|
|
70
|
+
app: { ui_library: 'antd' },
|
|
71
|
+
layers: { portal_roles: ['option', 'listbox', 'menu', 'menuitem', 'tooltip'] },
|
|
72
|
+
widgets: { select: 'antd-select' },
|
|
73
|
+
},
|
|
74
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from '@sun-asterisk/sungen';
|
|
27
27
|
import { loadCatalog, viewpointGate, assertionDepth, dataThemesFor } from '@sun-asterisk/sungen';
|
|
28
28
|
|
|
29
|
+
import { APP_PROFILE_PRESETS } from './app-profile-presets';
|
|
29
30
|
import { setupPatterns } from './patterns/setup-patterns';
|
|
30
31
|
import { navigationPatterns } from './patterns/navigation-patterns';
|
|
31
32
|
import { formPatterns } from './patterns/form-patterns';
|
|
@@ -113,6 +114,7 @@ export function register(registry: CapabilityRegistry): void {
|
|
|
113
114
|
viewpoints: loadCatalog, // UI owns the 17-pattern universal viewpoint catalog
|
|
114
115
|
gateProvider: uiGateProvider as (i: unknown) => unknown, // viewpoint coverage + depth
|
|
115
116
|
sensors: [uiViewpointGateSensor],
|
|
117
|
+
appProfilePresets: APP_PROFILE_PRESETS, // qa/app.yaml `extends: native|radix|antd`
|
|
116
118
|
});
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ParsedStep } from '@sun-asterisk/sungen';
|
|
2
2
|
import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
|
|
3
|
+
import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Capture & collection patterns (P5) — enable cross-screen data consistency and
|
|
@@ -20,6 +21,44 @@ import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
|
|
|
20
21
|
* Roadmap Q2 — the fix for "each card exposes X" claims.
|
|
21
22
|
*/
|
|
22
23
|
export const capturePatterns: StepPattern[] = [
|
|
24
|
+
{
|
|
25
|
+
// FR2 (issue #468): `User remember [Col] column in [Table] table row {{n}} as {{var}}`
|
|
26
|
+
// — read a cell by POSITION so a later `… in [Table] table row {{n}}` action targets
|
|
27
|
+
// the same `getByRole('row').nth(...)` chain (no read-then-act two-query race).
|
|
28
|
+
// The cell comes from the table entry's `columns:` config (index); without one, the
|
|
29
|
+
// whole row's text is captured.
|
|
30
|
+
// NOTE: the `table-` name prefix opts out of generateStep's parentRef auto-scoping
|
|
31
|
+
// (the table locator is resolved internally from the step text, like all table-* patterns).
|
|
32
|
+
name: 'table-capture-row-nth',
|
|
33
|
+
matcher: (step: ParsedStep) =>
|
|
34
|
+
/\bremember\b/i.test(step.text) && /\bas\s+\{\{[^}]+\}\}/i.test(step.text) && TABLE_ROW_NTH.test(step.text),
|
|
35
|
+
resolver: (step, context): StepTemplateData => {
|
|
36
|
+
const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
|
|
37
|
+
const columnName = brackets[0]?.replace(/[\[\]]/g, '') || '';
|
|
38
|
+
const tableName = brackets.length >= 2 ? brackets[1].replace(/[\[\]]/g, '') : '';
|
|
39
|
+
const rowToken = step.text.match(TABLE_ROW_NTH)![1];
|
|
40
|
+
const varName = step.text.match(/\bas\s+\{\{([^}]+)\}\}/i)![1].trim();
|
|
41
|
+
|
|
42
|
+
const tableResolved = context.selectorResolver.resolveSelector(
|
|
43
|
+
tableName, context.featureName, 'table', 0
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Cell by column index from the table entry's `columns:` config.
|
|
47
|
+
const columnKey = columnName.normalize('NFC').toLowerCase().trim().replace(/\s+/g, ' ');
|
|
48
|
+
const column = tableResolved.columns?.[columnKey];
|
|
49
|
+
const cellExpr = column !== undefined
|
|
50
|
+
? `tableRow.getByRole('cell').nth(${column.index})`
|
|
51
|
+
: 'tableRow';
|
|
52
|
+
|
|
53
|
+
context.dataResolver.registerCaptured(varName);
|
|
54
|
+
return {
|
|
55
|
+
templateName: 'capture-row-column',
|
|
56
|
+
data: { ...tableResolved, varName, cellExpr, rowIndexExpr: rowIndexExpr(rowToken, context) },
|
|
57
|
+
comment: `Remember ${columnName} column in ${tableName} table row ${rowToken} as ${varName}`,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
priority: 36,
|
|
61
|
+
},
|
|
23
62
|
{
|
|
24
63
|
name: 'capture-variable',
|
|
25
64
|
matcher: (step: ParsedStep) =>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ParsedStep } from '@sun-asterisk/sungen';
|
|
2
|
-
import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
|
|
2
|
+
import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Form action patterns: fill, type, select, check, uncheck
|
|
@@ -52,6 +52,28 @@ export const formPatterns: StepPattern[] = [
|
|
|
52
52
|
value = step.value!;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Widget recipe hook: per-element `variant:` wins; else, for a date-picker element,
|
|
56
|
+
// the profile's `widgets.datepicker` (custom datepickers — readonly inputs that need
|
|
57
|
+
// open-panel choreography). Resolves to a recipe with a `fill` intent.
|
|
58
|
+
const isDatePicker = /date-?picker|calendar/.test(String(step.elementType || ''));
|
|
59
|
+
const fillVariant = resolved.variant || (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker'] : undefined);
|
|
60
|
+
const fillRecipe = fillVariant ? context.appProfile?.recipes?.[fillVariant]?.['fill'] : undefined;
|
|
61
|
+
if (fillRecipe) {
|
|
62
|
+
try {
|
|
63
|
+
const steps = expandRecipe(fillRecipe as unknown[], {
|
|
64
|
+
triggerCode: context.renderLocator(resolved),
|
|
65
|
+
value,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
templateName: 'recipe-sequence',
|
|
69
|
+
data: { steps },
|
|
70
|
+
comment: `Fill ${step.selectorRef} with ${step.dataRef || step.value} (${fillVariant} recipe)`,
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
// malformed recipe → generic fill below
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
// Use pressSequentially template for contenteditable/rich-text editors (auto-detected by live-scan)
|
|
56
78
|
// or when Gherkin explicitly uses 'editor' element type
|
|
57
79
|
const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
|
|
@@ -132,6 +154,29 @@ export const formPatterns: StepPattern[] = [
|
|
|
132
154
|
// A native <select> is detected by its ARIA role, or by an explicit `variant: native`
|
|
133
155
|
// on the selector entry (lets a CSS/#id-located select opt into .selectOption()).
|
|
134
156
|
const isNativeSelect = nativeSelectRoles.includes(resolved.role) || resolved.variant === 'native';
|
|
157
|
+
// Widget variant resolution (spec §3): per-element `variant:` wins over the app
|
|
158
|
+
// profile's `widgets.select`. A matching recipe's `select` intent expands to
|
|
159
|
+
// Playwright lines at compile time; native/unknown variants keep the legacy fork.
|
|
160
|
+
const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
|
|
161
|
+
const recipeSteps = variantName && variantName !== 'native'
|
|
162
|
+
? context.appProfile?.recipes?.[variantName]?.['select']
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
165
|
+
if (recipeSteps) {
|
|
166
|
+
try {
|
|
167
|
+
const steps = expandRecipe(recipeSteps as unknown[], {
|
|
168
|
+
triggerCode: context.renderLocator(resolved),
|
|
169
|
+
value,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
templateName: 'recipe-sequence',
|
|
173
|
+
data: { steps },
|
|
174
|
+
comment: `Select ${step.dataRef || step.value} in ${step.selectorRef} (${variantName} recipe)`,
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
// malformed recipe → generic fallback below
|
|
178
|
+
}
|
|
179
|
+
}
|
|
135
180
|
|
|
136
181
|
if (isNativeSelect) {
|
|
137
182
|
templateName = 'select-action';
|
|
@@ -9,6 +9,8 @@ import { escapeRegex, pathToRegexSource } from '../utils/url-path-regex';
|
|
|
9
9
|
function extractWaitState(text: string): string {
|
|
10
10
|
if (/\bhidden\b/.test(text)) return 'hidden';
|
|
11
11
|
if (/\bdetached\b/.test(text)) return 'detached';
|
|
12
|
+
if (/\bdisabled\b/.test(text)) return 'disabled';
|
|
13
|
+
if (/\benabled\b/.test(text)) return 'enabled';
|
|
12
14
|
if (/\bvisible\b/.test(text)) return 'visible';
|
|
13
15
|
return 'visible';
|
|
14
16
|
}
|
|
@@ -32,7 +34,42 @@ function elementKeywordToRole(text: string): string | null {
|
|
|
32
34
|
*/
|
|
33
35
|
export const interactionPatterns: StepPattern[] = [
|
|
34
36
|
// === Browser Alert (system dialog) patterns ===
|
|
35
|
-
//
|
|
37
|
+
// Compound form (preferred since v3.3): trigger + dialog response in ONE step, in the
|
|
38
|
+
// user's natural order — the compiler emits the listener FIRST (Playwright requires
|
|
39
|
+
// registration before the triggering action; a late handler lets the dialog
|
|
40
|
+
// auto-dismiss with no error):
|
|
41
|
+
// When User click [Đăng xuất] button and accept [OK] alert
|
|
42
|
+
// When User click [Xoá] button and dismiss [Cancel] alert
|
|
43
|
+
{
|
|
44
|
+
name: 'click-with-alert',
|
|
45
|
+
matcher: (step: ParsedStep) =>
|
|
46
|
+
/\b(click|clicks)\b/.test(step.text) &&
|
|
47
|
+
/\band\s+(accept|accepts|dismiss|dismisses)\s+\[[^\]]*\]\s+alert\b/i.test(step.text) &&
|
|
48
|
+
(step.text.match(/\[[^\]]+\]/g) || []).length >= 2,
|
|
49
|
+
resolver: (step, context) => {
|
|
50
|
+
// brackets[0] = trigger element, brackets[1] = alert button (verb decides the action)
|
|
51
|
+
const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
|
|
52
|
+
const triggerName = brackets[0].replace(/[\[\]]/g, '');
|
|
53
|
+
const dialogAction = /\band\s+dismiss(?:es)?\b/i.test(step.text) ? 'dismiss' : 'accept';
|
|
54
|
+
|
|
55
|
+
// Element type = the word right after the trigger bracket (button/link/…)
|
|
56
|
+
const typeMatch = step.text.match(/\]\s+([\w-]+)\s+and\b/i);
|
|
57
|
+
const triggerType = typeMatch ? typeMatch[1].toLowerCase() : 'button';
|
|
58
|
+
|
|
59
|
+
const resolved = context.selectorResolver.resolveSelector(
|
|
60
|
+
triggerName, context.featureName, triggerType, step.nth
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
templateName: 'click-with-alert-action',
|
|
65
|
+
data: { ...resolved, dialogAction },
|
|
66
|
+
comment: `Click ${triggerName} and ${dialogAction} the browser dialog`,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
priority: 21,
|
|
70
|
+
},
|
|
71
|
+
// Two-step form (still valid): the alert step must appear BEFORE the click that
|
|
72
|
+
// triggers the alert in Gherkin — SG-W007 warns when it is written after:
|
|
36
73
|
// When User click [OK] alert ← registers page.once('dialog', ...)
|
|
37
74
|
// And User click [Delete] button ← triggers the alert
|
|
38
75
|
{
|
|
@@ -98,12 +135,14 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
98
135
|
resolver: (step, context) => {
|
|
99
136
|
let selectorValue = '';
|
|
100
137
|
let nth: number | undefined;
|
|
138
|
+
let exact = false;
|
|
101
139
|
try {
|
|
102
140
|
const resolved = context.selectorResolver.resolveSelector(
|
|
103
141
|
step.selectorRef!, context.featureName, step.elementType, step.nth
|
|
104
142
|
);
|
|
105
143
|
selectorValue = resolved.value || '';
|
|
106
144
|
nth = resolved.nth;
|
|
145
|
+
exact = resolved.exact === true;
|
|
107
146
|
} catch (error) {
|
|
108
147
|
selectorValue = '';
|
|
109
148
|
nth = undefined;
|
|
@@ -122,7 +161,7 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
122
161
|
|
|
123
162
|
return {
|
|
124
163
|
templateName: 'unknown-element-action',
|
|
125
|
-
data: { selectorValue, dataValue, action: actionMethod, nth },
|
|
164
|
+
data: { selectorValue, dataValue, action: actionMethod, nth, exact },
|
|
126
165
|
comment: `${actionMethod.charAt(0).toUpperCase() + actionMethod.slice(1)} ${step.selectorRef} with ${step.dataRef}`,
|
|
127
166
|
};
|
|
128
167
|
},
|
|
@@ -336,6 +375,21 @@ export const interactionPatterns: StepPattern[] = [
|
|
|
336
375
|
},
|
|
337
376
|
priority: 9,
|
|
338
377
|
},
|
|
378
|
+
{
|
|
379
|
+
// FR3 (issue #468): `User wait for [T] table to refresh` — the dedicated wait for
|
|
380
|
+
// filter/search/pagination round-trips. Signal = the app profile's loading indicator
|
|
381
|
+
// (qa/app.yaml feedback.loading.indicator, default [aria-busy="true"]): soft-wait for
|
|
382
|
+
// it to appear (the spinner may already be gone), hard-wait for it to disappear.
|
|
383
|
+
name: 'wait-table-refresh',
|
|
384
|
+
matcher: (step: ParsedStep) =>
|
|
385
|
+
/\bwait(?:s)?\s+for\b/i.test(step.text) && /\btable\s+to\s+refresh\b/i.test(step.text),
|
|
386
|
+
resolver: (step, context) => ({
|
|
387
|
+
templateName: 'wait-table-refresh',
|
|
388
|
+
data: { indicator: context.appProfile?.loadingIndicator ?? '[aria-busy="true"]' },
|
|
389
|
+
comment: `Wait for ${step.selectorRef || 'table'} to refresh`,
|
|
390
|
+
}),
|
|
391
|
+
priority: 12,
|
|
392
|
+
},
|
|
339
393
|
{
|
|
340
394
|
name: 'wait-for-element-with-text',
|
|
341
395
|
matcher: (step: ParsedStep) =>
|
|
@@ -35,19 +35,35 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
35
35
|
const finalPath = resolvePathVariables(path, context.scenarioSteps || []);
|
|
36
36
|
const isAbsoluteUrl = /^https?:\/\//.test(finalPath);
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// `[X] page with {{v}}` — the query/fragment value parsed into dataRef (issue #464).
|
|
39
|
+
// Raw concat, no "smart" URL merging: the primary use case is injecting malformed
|
|
40
|
+
// query input, so the compiler must not sanitize what the test wants broken.
|
|
41
|
+
// Runtime mode resolves to a __SUNGEN_TD__ marker → `path + testData.get(...)`.
|
|
42
|
+
let query = '';
|
|
43
|
+
if (step.dataRef) {
|
|
44
|
+
try {
|
|
45
|
+
query = context.dataResolver.resolveData(step.dataRef, context.featureName);
|
|
46
|
+
} catch {
|
|
47
|
+
query = `\${${step.dataRef}}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
|
|
51
|
+
|
|
52
|
+
// Then User is on [X] page — assert URL, don't navigate. The query is asserted
|
|
53
|
+
// separately (toHaveURL's regex stays compile-time escapable; the query value is
|
|
54
|
+
// runtime data with regex-special characters).
|
|
39
55
|
if (isThen) {
|
|
40
56
|
return {
|
|
41
57
|
templateName: 'route-assertion',
|
|
42
|
-
data: { path: finalPath },
|
|
43
|
-
comment: step.selectorRef ? `Assert on ${step.selectorRef} page` : `Assert page route`,
|
|
58
|
+
data: { path: finalPath, query },
|
|
59
|
+
comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
|
|
44
60
|
};
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
return {
|
|
48
64
|
templateName: 'navigation',
|
|
49
|
-
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath },
|
|
50
|
-
comment: step.selectorRef ? `Open ${step.selectorRef} page` : `Navigate to page`,
|
|
65
|
+
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath + query },
|
|
66
|
+
comment: step.selectorRef ? `Open ${step.selectorRef} page${withSuffix}` : `Navigate to page`,
|
|
51
67
|
};
|
|
52
68
|
},
|
|
53
69
|
priority: 16,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { StepPattern, PatternContext } from '@sun-asterisk/sungen';
|
|
19
|
+
import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
|
|
19
20
|
|
|
20
21
|
/** Helper: check if current step is in a Given context */
|
|
21
22
|
function isGivenContext(context: PatternContext): boolean {
|
|
@@ -179,6 +180,57 @@ export const tablePatterns: StepPattern[] = [
|
|
|
179
180
|
},
|
|
180
181
|
|
|
181
182
|
// "User click [Act] button in [Table] table with {{filter}}" — action in row
|
|
183
|
+
{
|
|
184
|
+
// FR2 (issue #468): positional row — `User click [Act] button in [Table] table row {{n}}`.
|
|
185
|
+
// Selecting by POSITION (not content) compiles remember+act steps onto the same
|
|
186
|
+
// `getByRole('row').nth(...)` chain, eliminating the read-then-act two-query race
|
|
187
|
+
// where a re-render between queries hits two different rows. Row numbering follows
|
|
188
|
+
// qa/app.yaml conventions.table (row_base, header_is_row).
|
|
189
|
+
name: 'table-action-in-row-nth',
|
|
190
|
+
matcher: (step) => {
|
|
191
|
+
return TABLE_ROW_NTH.test(step.text) &&
|
|
192
|
+
/\b(click|clicks|check|checks|uncheck|unchecks)\b/i.test(step.text) &&
|
|
193
|
+
!/\bremember\b/i.test(step.text) &&
|
|
194
|
+
/\bin\b.*\btable\b/i.test(step.text);
|
|
195
|
+
},
|
|
196
|
+
resolver: (step, context) => {
|
|
197
|
+
const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
|
|
198
|
+
const elementName = brackets[0]?.replace(/[\[\]]/g, '') || '';
|
|
199
|
+
const tableName = brackets.length >= 2 ? brackets[1].replace(/[\[\]]/g, '') : '';
|
|
200
|
+
const rowToken = step.text.match(TABLE_ROW_NTH)![1];
|
|
201
|
+
|
|
202
|
+
const tableResolved = context.selectorResolver.resolveSelector(
|
|
203
|
+
tableName, context.featureName, 'table', 0
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const actionMatch = step.text.match(/\b(click|check|uncheck)\b/i);
|
|
207
|
+
const action = actionMatch ? actionMatch[1].toLowerCase() : 'click';
|
|
208
|
+
|
|
209
|
+
const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
|
|
210
|
+
const elementType = elementTypeMatch ? elementTypeMatch[1].toLowerCase() : 'button';
|
|
211
|
+
const elementRole = typeToRole[elementType] || elementType;
|
|
212
|
+
|
|
213
|
+
let actionLocator: string;
|
|
214
|
+
try {
|
|
215
|
+
const elementResolved = context.selectorResolver.resolveSelector(
|
|
216
|
+
elementName, context.featureName, elementType, step.nth
|
|
217
|
+
);
|
|
218
|
+
actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
|
|
219
|
+
} catch {
|
|
220
|
+
const escapedName = String(elementName)
|
|
221
|
+
.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
222
|
+
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
223
|
+
actionLocator = `tableRow.getByRole('${elementRole}', { name: '${escapedName}' })`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
templateName: 'table-action-in-row-nth',
|
|
228
|
+
data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr: rowIndexExpr(rowToken, context) },
|
|
229
|
+
comment: `${action} ${elementName} in ${tableName} table row ${rowToken}`,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
priority: 18,
|
|
233
|
+
},
|
|
182
234
|
{
|
|
183
235
|
name: 'table-action-in-row',
|
|
184
236
|
matcher: (step) => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PatternContext } from '@sun-asterisk/sungen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Positional-row index expression for `… table row {{n}}` steps (FR2, issue #468).
|
|
5
|
+
*
|
|
6
|
+
* QA counts rows from `conventions.table.row_base` (default 1) and the header <tr>
|
|
7
|
+
* usually carries role=row at getByRole('row') index 0 (`header_is_row: false`), so:
|
|
8
|
+
* nth index = n + (header_is_row ? 0 : 1) - row_base
|
|
9
|
+
*
|
|
10
|
+
* `rowToken` is the literal number or `{{var}}` from the step text. A runtime marker
|
|
11
|
+
* becomes `Number(testData.get('var'))` via the runtime-data transform, so the offset
|
|
12
|
+
* is emitted as ` + k` / ` - k` around it.
|
|
13
|
+
*/
|
|
14
|
+
export function rowIndexExpr(rowToken: string, context: PatternContext): string {
|
|
15
|
+
const rowBase = context.appProfile?.tableRowBase ?? 1;
|
|
16
|
+
const offset = (context.appProfile?.tableHeaderIsRow ? 0 : 1) - rowBase;
|
|
17
|
+
|
|
18
|
+
const varMatch = rowToken.match(/^\{\{([^}]+)\}\}$/);
|
|
19
|
+
if (!varMatch) return String(Number(rowToken) + offset);
|
|
20
|
+
|
|
21
|
+
let expr: string;
|
|
22
|
+
try {
|
|
23
|
+
expr = context.dataResolver.resolveData(varMatch[1].trim(), context.featureName);
|
|
24
|
+
} catch {
|
|
25
|
+
expr = `\${${varMatch[1].trim()}}`;
|
|
26
|
+
}
|
|
27
|
+
if (/^\d+$/.test(expr)) return String(Number(expr) + offset); // inline-data mode
|
|
28
|
+
if (offset === 0) return expr;
|
|
29
|
+
return offset > 0 ? `${expr} + ${offset}` : `${expr} - ${-offset}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Matches `table row 2` / `table row {{n}}` and captures the row token. */
|
|
33
|
+
export const TABLE_ROW_NTH = /\btable\s+row\s+(\d+|\{\{[^}]+\}\})/i;
|