@sungen/driver-ui 3.2.31 → 3.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-ui",
3
- "version": "3.2.31",
3
+ "version": "3.2.33",
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.31"
16
+ "@sun-asterisk/sungen": "3.2.33"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -2,7 +2,7 @@ import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  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
- import { pathToRegexSource } from '../utils/url-path-regex';
5
+ import { urlToPatternParts } from '../utils/url-path-regex';
6
6
  import { resolveColumnHeaderName } from '../utils/column-header';
7
7
 
8
8
  /**
@@ -65,11 +65,7 @@ export const assertionPatterns: StepPattern[] = [
65
65
  resolver: (step, context): StepTemplateData => {
66
66
  let dataValue = step.selectorRef || '';
67
67
  if (step.dataRef) {
68
- try {
69
- dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
70
- } catch {
71
- dataValue = `\${${step.dataRef}}`;
72
- }
68
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
73
69
  }
74
70
  return {
75
71
  templateName: 'alert-text-assertion',
@@ -92,11 +88,7 @@ export const assertionPatterns: StepPattern[] = [
92
88
  (step.elementType === 'column' || step.elementType === 'columnheader'),
93
89
  resolver: (step, context): StepTemplateData => {
94
90
  let dataValue: string;
95
- try {
96
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
97
- } catch (error) {
98
- dataValue = `\${${step.dataRef}}`;
99
- }
91
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
100
92
 
101
93
  // getByRole('row') includes header row as nth(0), so Gherkin nth=1 maps to locator nth(1)
102
94
  const rowNth = step.nth || 1;
@@ -138,6 +130,8 @@ export const assertionPatterns: StepPattern[] = [
138
130
  // step is a URL-only oracle, which a client-side router can satisfy for a few hundred
139
131
  // milliseconds before a guard bounces the user away (#662).
140
132
  let anchor: Record<string, unknown> | undefined;
133
+ // The page entry's `query: any` (#748) — set below, once the entry is known to BE a page.
134
+ let ignoreQuery = false;
141
135
 
142
136
  // If selector is present, extract path from selector's value attribute
143
137
  if (step.selectorRef) {
@@ -155,6 +149,10 @@ export const assertionPatterns: StepPattern[] = [
155
149
  // the feature path rather than compiling `toHaveURL(/button/)`.
156
150
  path = resolved.selectorType === 'page' ? (resolved.value || fallbackPath) : fallbackPath;
157
151
  if (resolved.selectorType === 'page' && resolved.anchor) anchor = { ...resolved.anchor };
152
+ // `query: any` — the query is not part of this page's identity (#748). Gated on the entry
153
+ // KIND for the same reason `value` and `anchor` are: a key collision can hand back a
154
+ // non-page entry, and reading a stray field off it would silently weaken the assertion.
155
+ if (resolved.selectorType === 'page' && resolved.query === 'any') ignoreQuery = true;
158
156
  } catch (error) {
159
157
  // Fallback to feature path if selector not found
160
158
  path = fallbackPath;
@@ -167,20 +165,19 @@ export const assertionPatterns: StepPattern[] = [
167
165
  // has asserted it since issue #464; this is the same assertion, so it asserts the same way.
168
166
  let query = '';
169
167
  if (step.dataRef) {
170
- try {
171
- query = context.dataResolver.resolveData(step.dataRef, context.featureName);
172
- } catch {
173
- query = `\${${step.dataRef}}`;
174
- }
168
+ query = context.dataResolver.resolveData(step.dataRef, context.featureName);
175
169
  }
176
- // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard)
177
- const pathRegex = pathToRegexSource(path);
170
+ // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard). An ABSOLUTE
171
+ // page value also yields a host pattern: matching only the path let this assertion pass on
172
+ // any host, so `see [Admin Jobs] page` declared as https://admin.example.com/jobs was
173
+ // satisfied by the public site's /jobs.
174
+ const { pathRegex, hostRegex } = urlToPatternParts(path);
178
175
  const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
179
176
  return {
180
177
  templateName: 'page-assertion',
181
178
  // `dataRef` rides along so a runtime failure can name the test-data key an author must go
182
179
  // fix — an empty/absent value is otherwise the hardest kind of red to trace.
183
- data: { pathRegex, query, dataRef: step.dataRef || '', anchor },
180
+ data: { pathRegex, hostRegex, query, dataRef: step.dataRef || '', anchor, ignoreQuery },
184
181
  comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
185
182
  };
186
183
  },
@@ -208,11 +205,7 @@ export const assertionPatterns: StepPattern[] = [
208
205
  } catch (error) {}
209
206
 
210
207
  let dataValue: string;
211
- try {
212
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
213
- } catch (error) {
214
- dataValue = `\${${step.dataRef}}`;
215
- }
208
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
216
209
 
217
210
  const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
218
211
 
@@ -281,11 +274,7 @@ export const assertionPatterns: StepPattern[] = [
281
274
  } catch (error) {}
282
275
 
283
276
  let dataValue: string;
284
- try {
285
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
286
- } catch (error) {
287
- dataValue = `\${${step.dataRef}}`;
288
- }
277
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
289
278
 
290
279
  const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
291
280
 
@@ -356,11 +345,7 @@ export const assertionPatterns: StepPattern[] = [
356
345
  } catch (error) {}
357
346
 
358
347
  let dataValue: string;
359
- try {
360
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
361
- } catch (error) {
362
- dataValue = `\${${step.dataRef}}`;
363
- }
348
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
364
349
 
365
350
  // "selected" mirrors selected-assertion: checkbox/radio/switch roles have no
366
351
  // aria-selected — their state is `checked`; other targets probe at runtime.
@@ -416,11 +401,7 @@ export const assertionPatterns: StepPattern[] = [
416
401
 
417
402
  // Resolve data variable value
418
403
  let dataValue: string;
419
- try {
420
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
421
- } catch (error) {
422
- dataValue = `\${${step.dataRef}}`;
423
- }
404
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
424
405
 
425
406
  // Resolve selector from YAML with feature context
426
407
  let resolved: any = {};
@@ -532,9 +513,15 @@ export const assertionPatterns: StepPattern[] = [
532
513
  const label = resolved.value !== undefined && resolved.value.trim() === ''
533
514
  ? undefined
534
515
  : step.selectorRef;
516
+ // The locator here is built from the CAPTION, not from `resolved` — the common case has no
517
+ // selectors.yaml entry at all, so `resolved` is `{}` and carries nothing. The scope
518
+ // therefore comes off the context, which `withParentScope` sets whether or not the entry
519
+ // lookup succeeded; without it `… in [Education Card 6] card` was dropped silently and the
520
+ // assertion read whichever card the page happened to render first (#747).
535
521
  const code = context.templateEngine.renderStep('label-value-assertion', {
536
522
  label,
537
523
  dataValue,
524
+ parentLocator: context.parentLocator,
538
525
  });
539
526
  return {
540
527
  code,
@@ -561,6 +548,9 @@ export const assertionPatterns: StepPattern[] = [
561
548
  name: hasName ? resolved.name : dataValue,
562
549
  exact: resolved.exact || false,
563
550
  nth: resolved.nth,
551
+ // Rebuilt field by field rather than spread from `resolved`, so the scope `withParentScope`
552
+ // stamped there never arrives — the label branch's twin defect (#747).
553
+ parentLocator: resolved.parentLocator ?? context.parentLocator,
564
554
  });
565
555
  return {
566
556
  code,
@@ -862,11 +852,7 @@ export const assertionPatterns: StepPattern[] = [
862
852
  resolver: (step, context): StepTemplateData => {
863
853
  // Resolve data reference to actual value
864
854
  let dataValue: string;
865
- try {
866
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
867
- } catch (error) {
868
- dataValue = `\${${step.dataRef}}`;
869
- }
855
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
870
856
 
871
857
  return {
872
858
  templateName: 'visible-assertion',
@@ -892,13 +878,9 @@ export const assertionPatterns: StepPattern[] = [
892
878
  if (match) {
893
879
  expectedCount = parseInt(match[1]);
894
880
  } else if (step.dataRef) {
895
- try {
896
- const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
897
- const parsed = parseInt(resolvedData);
898
- expectedCount = isNaN(parsed) ? 1 : parsed;
899
- } catch {
900
- expectedCount = `\${${step.dataRef}}`;
901
- }
881
+ const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
882
+ const parsed = parseInt(resolvedData);
883
+ expectedCount = isNaN(parsed) ? 1 : parsed;
902
884
  }
903
885
 
904
886
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
@@ -923,13 +905,9 @@ export const assertionPatterns: StepPattern[] = [
923
905
  expectedCount = parseInt(match[1]);
924
906
  } else if (step.dataRef) {
925
907
  // Resolve data reference: "count {{number_items}}"
926
- try {
927
- const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
928
- const parsed = parseInt(resolvedData);
929
- expectedCount = isNaN(parsed) ? 1 : parsed;
930
- } catch {
931
- expectedCount = `\${${step.dataRef}}`;
932
- }
908
+ const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
909
+ const parsed = parseInt(resolvedData);
910
+ expectedCount = isNaN(parsed) ? 1 : parsed;
933
911
  }
934
912
 
935
913
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
@@ -71,10 +71,58 @@ export const capturePatterns: StepPattern[] = [
71
71
  step.selectorRef!, undefined, step.elementType, step.nth,
72
72
  );
73
73
  const varName = step.dataRef!;
74
- const isValue = /\bvalue\b/i.test(step.text);
74
+ // What to capture is stated BETWEEN the reference and `as` — nowhere else (#736). Scanning the
75
+ // whole sentence also saw the word inside the reference name and inside the variable name, so
76
+ // `remember [Name Value] text as {{v}}` and `remember [Label] text as {{value}}` both compiled
77
+ // to .inputValue() and died on a <span> with "Node is not an <input>".
78
+ //
79
+ // `step.elementType` alone is NOT enough: it holds only the FIRST token after `]`, so the
80
+ // documented `remember [Email] field value as {{v}}` reads as 'field' and would lose its value
81
+ // capture. Slicing after `]` first is also what makes the `as` split safe — a reference like
82
+ // `[As Of Date]` never reaches it.
83
+ const descriptor = step.text.slice(step.text.indexOf(']') + 1).split(/\bas\b/i)[0];
84
+ const isValue = /\bvalue\b/i.test(descriptor);
75
85
  // Register so later `{{varName}}` references resolve to testData.get(varName)
76
86
  // and skip YAML validation.
77
87
  context.dataResolver.registerCaptured(varName);
88
+ // A capture reads ONE element, and the template now takes the first match rather than dying
89
+ // in strict mode. That is the right default — the documented workaround for the failure was
90
+ // `nth: 0`, i.e. this — but taking the first of many is a decision, and nothing else would
91
+ // ever say so: a capture feeds later steps, so a value read off the wrong row travels
92
+ // silently into whatever consumes it, possibly in another file.
93
+ //
94
+ // Reported on what the entry PROVES about cardinality, not on its strategy name. An id, a
95
+ // testid, a role WITH an accessible name, a label or a placeholder each address one thing by
96
+ // construction. `getByText` does not — it matches every element containing the text, which is
97
+ // exactly a list — and neither does a bare role or a class selector. The earlier reading of
98
+ // this rule ("warn for locator/css") got both halves wrong: it fired on `#order-id`, which is
99
+ // singular by definition, and stayed silent on `type: text`, which the `.first()` above
100
+ // silently narrows.
101
+ const value = String(resolved.value ?? '');
102
+ // Strategies that address ONE element by construction. The mobile native four are here for the
103
+ // same reason `testid` is: they are hand-written targeting expressions naming an element by an
104
+ // identifying attribute, so warning on them would fire on most captures in a mobile suite.
105
+ const IDENTIFYING = new Set([
106
+ 'id', 'testid', 'label', 'placeholder',
107
+ 'accessibility-id', 'xpath', 'android-uiautomator', 'ios-predicate',
108
+ ]);
109
+ const singular =
110
+ resolved.nth !== undefined
111
+ || IDENTIFYING.has(String(resolved.strategy))
112
+ || (resolved.strategy === 'role' && !!resolved.name)
113
+ || ((resolved.strategy === 'locator' || resolved.strategy === 'css') && value.includes('#'));
114
+ if (!singular) {
115
+ const fix = resolved.strategy === 'role'
116
+ ? 'give the role an accessible name, or declare the index you want ("nth: 0")'
117
+ : 'declare the index you want ("nth: 0"), or narrow the selector to the one element';
118
+ context.diagnostics?.add('SG-W036',
119
+ `"[${step.selectorRef}]" is captured through a ${resolved.strategy} selector `
120
+ + `("${value}") that can match several elements and declares no "nth:", so this step reads `
121
+ + `whichever one the page yields FIRST. If it describes every item in a list, the captured `
122
+ + `{{${varName}}} is the first row's value rather than the one the scenario means. `
123
+ + `To say the first one is meant, ${fix}.`,
124
+ { feature: context.featureName, step: step.text });
125
+ }
78
126
  return {
79
127
  templateName: 'capture-variable',
80
128
  data: { ...resolved, varName, capture: isValue ? 'inputValue' : 'textContent' },
@@ -20,11 +20,7 @@ export const formPatterns: StepPattern[] = [
20
20
 
21
21
  let fileName: string;
22
22
  if (step.dataRef) {
23
- try {
24
- fileName = context.dataResolver.resolveData(step.dataRef, context.featureName);
25
- } catch (error) {
26
- fileName = `\${${step.dataRef}}`;
27
- }
23
+ fileName = context.dataResolver.resolveData(step.dataRef, context.featureName);
28
24
  } else {
29
25
  fileName = step.value!;
30
26
  }
@@ -46,11 +42,7 @@ export const formPatterns: StepPattern[] = [
46
42
 
47
43
  let value: string;
48
44
  if (step.dataRef) {
49
- try {
50
- value = context.dataResolver.resolveData(step.dataRef, context.featureName);
51
- } catch (error) {
52
- value = `\${${step.dataRef}}`;
53
- }
45
+ value = context.dataResolver.resolveData(step.dataRef, context.featureName);
54
46
  } else {
55
47
  value = step.value!;
56
48
  }
@@ -169,11 +161,7 @@ export const formPatterns: StepPattern[] = [
169
161
 
170
162
  let value: string;
171
163
  if (step.dataRef) {
172
- try {
173
- value = context.dataResolver.resolveData(step.dataRef, context.featureName);
174
- } catch (error) {
175
- value = `\${${step.dataRef}}`;
176
- }
164
+ value = context.dataResolver.resolveData(step.dataRef, context.featureName);
177
165
  } else {
178
166
  value = step.value!;
179
167
  }
@@ -1,6 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern } from '@sun-asterisk/sungen';
3
- import { escapeRegex, pathToRegexSource } from '../utils/url-path-regex';
3
+ import { urlToPatternParts } from '../utils/url-path-regex';
4
4
 
5
5
  /**
6
6
  * Extract Playwright waitFor state from step text.
@@ -163,11 +163,7 @@ export const interactionPatterns: StepPattern[] = [
163
163
  !!step.dataRef,
164
164
  resolver: (step, context) => {
165
165
  let fillValue: string;
166
- try {
167
- fillValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
168
- } catch {
169
- fillValue = `\${${step.dataRef}}`;
170
- }
166
+ fillValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
171
167
  return {
172
168
  templateName: 'alert-fill-action',
173
169
  data: { fillValue },
@@ -221,11 +217,7 @@ export const interactionPatterns: StepPattern[] = [
221
217
  if (step.value) {
222
218
  dataValue = step.value;
223
219
  } else {
224
- try {
225
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
226
- } catch {
227
- dataValue = `\${${step.dataRef}}`;
228
- }
220
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
229
221
  }
230
222
  const { name: _name, ...resolvedWithoutName } = resolved;
231
223
 
@@ -261,11 +253,7 @@ export const interactionPatterns: StepPattern[] = [
261
253
  }
262
254
 
263
255
  let dataValue: string;
264
- try {
265
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
266
- } catch (error) {
267
- dataValue = `\${${step.dataRef}}`;
268
- }
256
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
269
257
 
270
258
  let actionMethod = 'click';
271
259
  if (step.text.includes('hover')) actionMethod = 'hover';
@@ -341,11 +329,7 @@ export const interactionPatterns: StepPattern[] = [
341
329
  if (step.value) {
342
330
  dataValue = step.value;
343
331
  } else {
344
- try {
345
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
346
- } catch {
347
- dataValue = `\${${step.dataRef}}`;
348
- }
332
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
349
333
  }
350
334
 
351
335
  const { name: _name, ...resolvedWithoutName } = resolved;
@@ -412,11 +396,7 @@ export const interactionPatterns: StepPattern[] = [
412
396
  if (step.value) {
413
397
  dataValue = step.value;
414
398
  } else {
415
- try {
416
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
417
- } catch {
418
- dataValue = `\${${step.dataRef}}`;
419
- }
399
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
420
400
  }
421
401
 
422
402
  const { name: _name, ...resolvedWithoutName } = resolved;
@@ -453,11 +433,7 @@ export const interactionPatterns: StepPattern[] = [
453
433
  (step.text.includes('clicks') || step.text.includes('click')) && !step.selectorRef && !!step.dataRef,
454
434
  resolver: (step, context) => {
455
435
  let value: string;
456
- try {
457
- value = context.dataResolver.resolveData(step.dataRef!, context.featureName);
458
- } catch (error) {
459
- value = `\${${step.dataRef}}`;
460
- }
436
+ value = context.dataResolver.resolveData(step.dataRef!, context.featureName);
461
437
 
462
438
  return {
463
439
  templateName: 'click-action',
@@ -503,11 +479,7 @@ export const interactionPatterns: StepPattern[] = [
503
479
  const role = elementKeywordToRole(step.text)!;
504
480
 
505
481
  let dataValue: string;
506
- try {
507
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
508
- } catch (error) {
509
- dataValue = `\${${step.dataRef}}`;
510
- }
482
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
511
483
 
512
484
  const state = extractWaitState(step.text);
513
485
 
@@ -562,25 +534,11 @@ export const interactionPatterns: StepPattern[] = [
562
534
  }
563
535
  }
564
536
 
565
- const isAbsoluteUrl = /^https?:\/\//.test(path);
566
- let pathRegex: string;
567
- let hostRegex: string | undefined;
568
- if (isAbsoluteUrl) {
569
- const url = new URL(path);
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);
577
- } else {
578
- pathRegex = pathToRegexSource(path);
579
- }
580
-
537
+ // Host and path are matched SEPARATELY since #677 — see urlToPatternParts, which now serves
538
+ // every URL pattern in the compiler so the two assertion forms cannot drift from this wait.
581
539
  return {
582
540
  templateName: 'wait-for-page',
583
- data: { pathRegex, hostRegex },
541
+ data: urlToPatternParts(path),
584
542
  comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
585
543
  };
586
544
  },
@@ -611,11 +569,7 @@ export const interactionPatterns: StepPattern[] = [
611
569
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
612
570
 
613
571
  let dataValue: string;
614
- try {
615
- dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
616
- } catch (error) {
617
- dataValue = `\${${step.dataRef}}`;
618
- }
572
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
619
573
 
620
574
  const state = extractWaitState(step.text);
621
575
 
@@ -1,7 +1,7 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern } from '@sun-asterisk/sungen';
3
3
  import { inferPath, resolvePathVariables, getPathCode } from '@sun-asterisk/sungen';
4
- import { pathToRegexSource } from '../utils/url-path-regex';
4
+ import { urlToPatternParts } from '../utils/url-path-regex';
5
5
 
6
6
  /**
7
7
  * `is on` names a NAVIGATION only for a page — or for an untyped / data-bound reference, the
@@ -44,6 +44,10 @@ export const navigationPatterns: StepPattern[] = [
44
44
  ? `/${step.selectorRef}/`
45
45
  : (context.featurePath || '/');
46
46
  let path = pathFallback;
47
+ // The page entry's `query: any` (#748) — the twin of the `see [X] page` reading. These two
48
+ // resolvers have drifted apart twice; a rule one honours and the other does not is exactly
49
+ // that drift, so it is read here on the same line as the path.
50
+ let ignoreQuery = false;
47
51
 
48
52
  if (step.selectorRef) {
49
53
  try {
@@ -57,6 +61,7 @@ export const navigationPatterns: StepPattern[] = [
57
61
  // documented fallback. These two resolvers have drifted apart twice already; keep them
58
62
  // spelling the same rule.
59
63
  path = resolved.selectorType === 'page' ? (resolved.value || pathFallback) : pathFallback;
64
+ if (resolved.selectorType === 'page' && resolved.query === 'any') ignoreQuery = true;
60
65
  } catch (error) {
61
66
  path = pathFallback;
62
67
  }
@@ -71,11 +76,7 @@ export const navigationPatterns: StepPattern[] = [
71
76
  // Runtime mode resolves to a __SUNGEN_TD$…$__ marker → `path + testData.get(...)`.
72
77
  let query = '';
73
78
  if (step.dataRef) {
74
- try {
75
- query = context.dataResolver.resolveData(step.dataRef, context.featureName);
76
- } catch {
77
- query = `\${${step.dataRef}}`;
78
- }
79
+ query = context.dataResolver.resolveData(step.dataRef, context.featureName);
79
80
  }
80
81
  const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
81
82
 
@@ -90,7 +91,8 @@ export const navigationPatterns: StepPattern[] = [
90
91
  return {
91
92
  templateName: 'route-assertion',
92
93
  // `dataRef` rides along so a runtime failure names the test-data key (see the `see` twin).
93
- data: { pathRegex: pathToRegexSource(path), query, dataRef: step.dataRef || '' },
94
+ // An absolute page value also carries a host pattern — see urlToPatternParts.
95
+ data: { ...urlToPatternParts(path), query, dataRef: step.dataRef || '', ignoreQuery },
94
96
  comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
95
97
  };
96
98
  }
@@ -157,7 +159,7 @@ export const navigationPatterns: StepPattern[] = [
157
159
  // Same rule as above — assert against the pre-resolution path so `:id` wildcards.
158
160
  return {
159
161
  templateName: 'route-assertion',
160
- data: { pathRegex: pathToRegexSource(inferredPath) },
162
+ data: urlToPatternParts(inferredPath),
161
163
  comment: `Assert on ${route}`,
162
164
  };
163
165
  }
@@ -187,7 +189,7 @@ export const navigationPatterns: StepPattern[] = [
187
189
  });
188
190
  return {
189
191
  templateName: 'route-assertion',
190
- data: { pathRegex: pathToRegexSource(inferredPath) },
192
+ data: urlToPatternParts(inferredPath),
191
193
  comment: `Assert current route is ${route}`,
192
194
  };
193
195
  },
@@ -63,7 +63,17 @@ function warnIfRowsLikelyMissing(
63
63
  ): void {
64
64
  const sink = context.diagnostics;
65
65
  if (!sink || resolved.rows) return;
66
- if (!['css', 'locator', 'testid', 'id'].includes(String(resolved.strategy))) return;
66
+ const strategy = String(resolved.strategy);
67
+ if (strategy === 'role') {
68
+ // A ROLE-located table was exempt outright, which read the exemption as "the author chose a
69
+ // role, so they thought about it". They did — but `role: list` or `role: region` contains no
70
+ // role=row either, so those tables hit the very failure this warning exists to name while
71
+ // staying completely silent. Only the roles that DO hold rows are evidence of anything.
72
+ const role = String(resolved.value ?? '').trim().toLowerCase();
73
+ if (role === 'table' || role === 'grid' || role === 'treegrid') return;
74
+ } else if (!['css', 'locator', 'testid', 'id'].includes(strategy)) {
75
+ return;
76
+ }
67
77
  if (/\btable\b/i.test(String(resolved.value ?? ''))) return;
68
78
  const key = `${context.featureName ?? ''}::${tableName}`;
69
79
  let seen = warnedTables.get(sink);
@@ -71,10 +81,29 @@ function warnIfRowsLikelyMissing(
71
81
  if (seen.has(key)) return;
72
82
  seen.add(key);
73
83
  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.`,
84
+ `table "[${tableName}]" is located by ${resolved.strategy} ("${resolved.value}") and declares no "rows:" — if its rows are not role="row" (a <div> list, or a role that holds no rows such as list/region), 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
85
  { feature: context.featureName, step: stepText });
76
86
  }
77
87
 
88
+ /**
89
+ * Does a declared `rows:` SHAPE select `<tr>` elements?
90
+ *
91
+ * `rows:` says what a row LOOKS like; it does not say the markup is not a table. `rows: 'tbody tr'`
92
+ * on a real <table> is a documented, working combination — it pins the row universe without
93
+ * changing what a row IS, so ARIA rows and `td, th` cells are still there. Only a shape built from
94
+ * something else (`div.job-row`, `li.card`) loses them, and that is the case the cell-reading steps
95
+ * cannot serve.
96
+ *
97
+ * Keying those steps on "a shape is declared" instead turned a correct table red: the refusal below
98
+ * would tell an author with a real <table> that their rows "are not ARIA rows", which is false, and
99
+ * a compile-time throw has to be right.
100
+ *
101
+ * Read as an element token so `div.tray` and `[data-tr]` are not mistaken for `tr`.
102
+ */
103
+ function rowShapeKeepsTableRoles(rows: string | undefined): boolean {
104
+ return !!rows && /(^|[\s>+~,(])tr\b/i.test(rows);
105
+ }
106
+
78
107
  /** Helper: check if current step is in a Given context */
79
108
  function isGivenContext(context: PatternContext): boolean {
80
109
  return context.effectiveKeyword === 'Given';
@@ -391,8 +420,16 @@ export const tablePatterns: StepPattern[] = [
391
420
  platformAlternative:
392
421
  'there is no HTML table on a native screen - tap the control on the row element itself (`When User tap [Row 1 Delete] button`)',
393
422
  matcher: (step) => {
423
+ // The VERB is required, exactly as the `-nth` twin above requires it. Without this test the
424
+ // pattern claimed every `in [T] table with {{v}}` sentence, verb or not, and the resolver's
425
+ // `|| 'click'` default then turned an ASSERTION into a click: `Then User see [Status] text in
426
+ // [Job Table] table with {{v}}` compiled to `.click()`, so the scenario went green having
427
+ // checked nothing. An action pattern must not be reachable from a sentence that names no
428
+ // action. `remember` is excluded for the same reason it is on the twin — it reads a value.
394
429
  return /\bin\b.*\btable\b/i.test(step.text) &&
395
430
  /\bwith\b/i.test(step.text) &&
431
+ /\b(clicks?|unchecks?|checks?)\b/i.test(step.text) &&
432
+ !/\bremember\b/i.test(step.text) &&
396
433
  !!step.dataRef &&
397
434
  step.elementType !== 'column' &&
398
435
  step.elementType !== 'row' &&
@@ -497,6 +534,58 @@ export const tablePatterns: StepPattern[] = [
497
534
  const resolvedRows = dataTable.rows.map(row => ({ cells: row.cells.map(resolveCell) }));
498
535
  const esc = (s: string) => s.replace(/'/g, "\\'");
499
536
 
537
+ // Where a CELL lives, decided at compile time from the table's own declaration.
538
+ //
539
+ // The runtime header read below only works on a real <table>: it reads `thead tr` and then
540
+ // addresses cells as `td, th`. A list whose rows are declared with a `rows:` SHAPE is built
541
+ // from <div>s, where `thead tr` is 0 elements forever — not a render race — and `td, th` is
542
+ // 0 elements too, so the step can never pass however long it waits. Declaring `columns:`
543
+ // replaces the read entirely: the index is known at compile time, and a column that names
544
+ // its own `elements.cell` addresses the cell without any table role at all.
545
+ const declaredColumns = resolved.columns ?? {};
546
+ const columnFor = (header: string) => {
547
+ const key = header.trim().toLowerCase().normalize('NFC');
548
+ const direct = declaredColumns[key];
549
+ if (direct) return direct;
550
+ return Object.values(declaredColumns).find(
551
+ (c) => String(c?.header ?? '').trim().toLowerCase().normalize('NFC') === key,
552
+ );
553
+ };
554
+ // A shape that still selects <tr> keeps its ARIA rows and its `td, th` cells, so the runtime
555
+ // header read below serves it exactly as it always did. Only a shape built from something else
556
+ // needs — and can use — the compile-time map.
557
+ const shapeHidesTableRoles = !!resolved.rows && !rowShapeKeepsTableRoles(resolved.rows);
558
+ // Usable means the column names the selector that matches its cell INSIDE a row. An `index:`
559
+ // alone cannot help here: it indexes the `td, th` set, which is empty on this markup.
560
+ const unmapped = headers.filter((h) => !columnFor(h)?.elements?.cell);
561
+ const mappedByCellSelector = shapeHidesTableRoles && unmapped.length === 0;
562
+
563
+ // A row shape that hides the table roles, with no cell selector to reach the cells by, cannot
564
+ // be served at all — refuse now, loudly, rather than emit an assertion that waits out every
565
+ // timeout before failing on a header row that will never exist.
566
+ if (shapeHidesTableRoles && unmapped.length > 0) {
567
+ const reason = `step "${step.text}" runs \`table match data:\` against table `
568
+ + `"[${step.selectorRef}]", whose rows are declared with a "rows:" shape — so the rows are `
569
+ + `not ARIA rows, their cells are not "td, th", and there is no "thead tr" to read the `
570
+ + `header row from. This assertion cannot pass on that markup however long it waits. `
571
+ + `Declare the columns it needs (${unmapped.map((h) => `"${h}"`).join(', ')}) on the table `
572
+ + `entry, each with the selector that matches its cell inside a row: "columns:\\n `
573
+ + `${(unmapped[0] ?? 'title').trim().toLowerCase()}:\\n index: 0\\n header: `
574
+ + `'${unmapped[0] ?? 'Title'}'\\n elements:\\n cell:\\n type: locator\\n `
575
+ + ` value: '.your-cell-class'".`;
576
+ context.diagnostics?.add('SG-E035', reason,
577
+ { feature: context.featureName, step: step.text });
578
+ return {
579
+ templateName: 'table-match-data',
580
+ data: {
581
+ ...resolved,
582
+ assertions: [`throw new Error(${JSON.stringify(`[sungen] ${reason}`)});`],
583
+ isGiven: isGivenContext(context),
584
+ },
585
+ comment: `Unsupported table match data on a declared row shape — see SG-E035: ${step.text}`,
586
+ };
587
+ }
588
+
500
589
  // Header → cell mapping (field report: the header row was parsed and then IGNORED —
501
590
  // rows were asserted as chained row-level hasText filters, so column-swapped data and
502
591
  // duplicate values like '-' still passed). Now:
@@ -514,12 +603,35 @@ export const tablePatterns: StepPattern[] = [
514
603
  assertions.push(
515
604
  `await expect(table.locator('[rowspan]:not([rowspan="1"]), [colspan]:not([colspan="1"])'), 'table match data: rowspan/colspan tables are not supported yet — header-to-cell mapping would be wrong').toHaveCount(0);`
516
605
  );
517
- assertions.push(
518
- `const __headers = (await table.locator('thead tr').first().locator('th, td').allTextContents()).map((t) => t.trim());`
519
- );
520
- assertions.push(
521
- `const __colIdx = (h: string): number => { const i = __headers.indexOf(h); if (i !== -1) return i; const c = __headers.filter((t) => t.includes(h)); if (c.length === 1) return __headers.indexOf(c[0]); throw new Error(\`table match data: header "\${h}" \${c.length ? 'is ambiguous' : 'not found'} in table header row [\${__headers.join(' | ')}]\`); };`
522
- );
606
+ // The cell expression for one DataTable column, scoped to `row`.
607
+ let cellExpr: (header: string) => string;
608
+ if (mappedByCellSelector) {
609
+ // Declared cell selectors: no header row is read at run time, so there is nothing to race
610
+ // with. Taken ONLY when the shape hides the table roles and every header has one — on a real
611
+ // table the runtime lookup is strictly better, because it is robust to extra DOM columns (a
612
+ // leading checkbox) that a compile-time index cannot see. Switching a working table onto
613
+ // compile-time indexes because it happens to declare `columns:` for some other step would
614
+ // silently re-point its assertions at different cells.
615
+ cellExpr = (header: string): string => context.renderLocator({
616
+ ...context.selectorResolver.resolveNestedEntry(columnFor(header)!.elements!.cell!, header),
617
+ parentLocator: 'row',
618
+ });
619
+ } else {
620
+ // The historical runtime mapping, for a table that declares nothing. The span guard above
621
+ // is NOT a wait: `toHaveCount(0)` is already true of an empty DOM, so it returns on the
622
+ // first poll and the one-shot read below then sees an empty header row. This assertion
623
+ // waits for the header row to actually hold cells before reading it.
624
+ assertions.push(
625
+ `await expect(table.locator('thead tr').first().locator('th, td'), 'table match data: the header row is still empty — the table had not rendered by the time this assertion read it').not.toHaveCount(0);`
626
+ );
627
+ assertions.push(
628
+ `const __headers = (await table.locator('thead tr').first().locator('th, td').allTextContents()).map((t) => t.trim());`
629
+ );
630
+ assertions.push(
631
+ `const __colIdx = (h: string): number => { const i = __headers.indexOf(h); if (i !== -1) return i; const c = __headers.filter((t) => t.includes(h)); if (c.length === 1) return __headers.indexOf(c[0]); throw new Error(\`table match data: header "\${h}" \${c.length ? 'is ambiguous' : 'not found'} in table header row [\${__headers.join(' | ')}]\`); };`
632
+ );
633
+ cellExpr = (header: string): string => `row.locator('td, th').nth(__colIdx('${esc(header)}'))`;
634
+ }
523
635
  for (const row of resolvedRows) {
524
636
  const filters = row.cells
525
637
  .map(cell => `.filter({ hasText: '${esc(cell)}' })`)
@@ -530,7 +642,7 @@ export const tablePatterns: StepPattern[] = [
530
642
  const header = headers[i];
531
643
  if (header === undefined) return;
532
644
  assertions.push(
533
- ` await expect(row.locator('td, th').nth(__colIdx('${esc(header)}'))).toHaveText('${esc(cell)}');`
645
+ ` await expect(${cellExpr(header)}).toHaveText('${esc(cell)}');`
534
646
  );
535
647
  });
536
648
  assertions.push(`}`);
@@ -24,12 +24,11 @@ export function rowIndexExpr(
24
24
  const varMatch = rowToken.match(/^\{\{([^}]+)\}\}$/);
25
25
  if (!varMatch) return String(Number(rowToken) + offset);
26
26
 
27
- let expr: string;
28
- try {
29
- expr = context.dataResolver.resolveData(varMatch[1].trim(), context.featureName);
30
- } catch {
31
- expr = `\${${varMatch[1].trim()}}`;
32
- }
27
+ // No fallback to the placeholder text. This value is spliced RAW into `.nth({{{rowIndexExpr}}})`,
28
+ // so `${n}` landed as a bare expression outside any template literal — a syntax error that took
29
+ // the whole spec file at parse time, not just the step, and named nothing useful. The throw
30
+ // reaches StepMapper.mapStep and becomes SG-E038 against this step alone (#781).
31
+ const expr: string = context.dataResolver.resolveData(varMatch[1].trim(), context.featureName);
33
32
  if (/^\d+$/.test(expr)) return String(Number(expr) + offset); // inline-data mode
34
33
  if (offset === 0) return expr;
35
34
  return offset > 0 ? `${expr} + ${offset}` : `${expr} - ${-offset}`;
@@ -37,3 +37,38 @@ export function pathToRegexSource(path: string): string {
37
37
  )
38
38
  .join('\\/');
39
39
  }
40
+
41
+ /** A page value split into the two patterns the runtime matches SEPARATELY. */
42
+ export interface UrlPatternParts {
43
+ /** Pattern for `u.pathname`. Always present. */
44
+ pathRegex: string;
45
+ /** Pattern for `u.hostname` — set only when the page value named an absolute URL. */
46
+ hostRegex?: string;
47
+ }
48
+
49
+ /**
50
+ * Split a page value into the host and path patterns the runtime matches separately.
51
+ *
52
+ * `u.hostname` never appears in `u.pathname`, so one concatenated regex cannot carry both — it
53
+ * only ever "worked" while the match ran unanchored across the whole URL, which is the looseness
54
+ * that let a wait resolve on a transient URL (#677). A page value that names an absolute URL
55
+ * therefore yields two patterns; a relative one yields the path pattern alone, exactly as before.
56
+ *
57
+ * Every caller that builds a pathname pattern for a URL assertion or wait goes through here, so
58
+ * the host half cannot be honoured on one step form and silently dropped on another: before this,
59
+ * only `wait for [X] page` split the host, while both assertion forms matched the path against
60
+ * ANY host — `see [X] page` with `https://admin.example.com/jobs` passed on the public site.
61
+ *
62
+ * Dynamic `:id` segments still wildcard; the host stays a literal. A value that starts like an
63
+ * absolute URL but does not parse falls back to the path-only reading rather than throwing — the
64
+ * pattern is then wrong in a way the author can read, which beats losing the step.
65
+ */
66
+ export function urlToPatternParts(path: string): UrlPatternParts {
67
+ if (!/^https?:\/\//i.test(path)) return { pathRegex: pathToRegexSource(path) };
68
+ try {
69
+ const url = new URL(path);
70
+ return { hostRegex: escapeRegex(url.hostname), pathRegex: pathToRegexSource(url.pathname) };
71
+ } catch {
72
+ return { pathRegex: pathToRegexSource(path) };
73
+ }
74
+ }