@sungen/driver-ui 3.2.18-beta.1 → 3.2.19

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.18-beta.1",
3
+ "version": "3.2.19",
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.18-beta.1"
16
+ "@sun-asterisk/sungen": "3.2.19"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ import { scrollPatterns } from './patterns/scroll-patterns';
37
37
  import { scopePatterns } from './patterns/scope-patterns';
38
38
  import { tablePatterns } from './patterns/table-patterns';
39
39
  import { capturePatterns } from './patterns/capture-patterns';
40
+ import { storagePatterns } from './patterns/storage-patterns';
41
+ import { focusPatterns } from './patterns/focus-patterns';
40
42
 
41
43
  /** Inputs the UI gate computation needs (passed by the audit engine via `gateProvider`). */
42
44
  export interface UiGateInput {
@@ -108,6 +110,8 @@ export function register(registry: CapabilityRegistry): void {
108
110
  ...scopePatterns,
109
111
  ...tablePatterns,
110
112
  ...capturePatterns,
113
+ ...storagePatterns,
114
+ ...focusPatterns,
111
115
  ],
112
116
  discovery: uiDiscovery,
113
117
  contextMapper: uiContextMapper,
@@ -1,5 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
3
+ import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
3
4
  import { pathToRegexSource } from '../utils/url-path-regex';
4
5
 
5
6
  /**
@@ -116,12 +117,25 @@ export const assertionPatterns: StepPattern[] = [
116
117
  }
117
118
  }
118
119
 
120
+ // `see [X] page with {{v}}` — the query/fragment value the parser put in dataRef. It used
121
+ // to be dropped here: the step compiled to a bare base-path toHaveURL that PASSES even when
122
+ // the query is wrong or gone, so a filter/search regression read as green. The `is on` twin
123
+ // has asserted it since issue #464; this is the same assertion, so it asserts the same way.
124
+ let query = '';
125
+ if (step.dataRef) {
126
+ try {
127
+ query = context.dataResolver.resolveData(step.dataRef, context.featureName);
128
+ } catch {
129
+ query = `\${${step.dataRef}}`;
130
+ }
131
+ }
119
132
  // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard)
120
133
  const pathRegex = pathToRegexSource(path);
134
+ const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
121
135
  return {
122
136
  templateName: 'page-assertion',
123
- data: { pathRegex },
124
- comment: step.selectorRef ? `Assert on ${step.selectorRef} page` : `Assert on page`,
137
+ data: { pathRegex, query },
138
+ comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
125
139
  };
126
140
  },
127
141
  priority: 13, // Higher priority than general visibility
@@ -473,11 +487,16 @@ export const assertionPatterns: StepPattern[] = [
473
487
  // list-member roles only (listitem/option/cell) — unique labelled elements keep
474
488
  // toHaveText (value check), and locator-type lists are unchanged. `row` is excluded
475
489
  // (table rows have dedicated existence patterns).
476
- const LIST_MEMBER_ROLES = new Set(['listitem', 'option', 'cell']);
490
+ // The name is NOT stripped here any more. It used to be dropped unconditionally, which also
491
+ // discarded a `name:` the user had declared explicitly — and the strip only ever protected
492
+ // THIS branch, leaving `is hidden` / `is disabled` to compile the invented name into a locator
493
+ // that matches nothing. The resolver now withholds the invented name at its source
494
+ // (LIST_MEMBER_ROLES in selector-resolver), so every branch is covered and an explicit name
495
+ // still reaches the locator. What stays here is the ROUTING: filter-then-visible instead of
496
+ // toHaveText, because a bare getByRole('listitem') strict-mode-fails when many match.
477
497
  if (resolved.strategy === 'role' && resolved.role && LIST_MEMBER_ROLES.has(resolved.role)) {
478
- const { name: _name, ...resolvedWithoutName } = resolved;
479
498
  const code = context.templateEngine.renderStep('visible-filtered-assertion', {
480
- ...resolvedWithoutName,
499
+ ...resolved,
481
500
  dataValue,
482
501
  });
483
502
  return {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Focus/tab-order assertion — a Gherkin data table declares the expected keyboard
3
+ * focus sequence:
4
+ *
5
+ * Then User see tab order:
6
+ * | Ref |
7
+ * | [Station Dropdown] |
8
+ * | [Departure Date] |
9
+ *
10
+ * Compiles to: focus row 1 (the pinned origin — there is deliberately no separate
11
+ * `focus [X]` step), then for each following row press Tab and assert that element
12
+ * is focused. Web-only: no Appium twin — a mobile feature using this step fails
13
+ * loudly at compile ("Template not found") instead of faking a result.
14
+ *
15
+ * Cell refs ARE selector references (unlike storage keys) — each resolves through
16
+ * selectors.yaml. Resolution failures are COLLECTED and thrown as one compile error
17
+ * listing every broken row: neither the fail-open `getByRole` guess some table
18
+ * paths use (a wrong a11y assertion on a deliverable), nor dying on the first typo.
19
+ */
20
+
21
+ import { StepPattern } from '@sun-asterisk/sungen';
22
+
23
+ // The step line carries no refs, so masked text == raw text. Anchored: exactly
24
+ // `see tab order:` — nothing else fits, nothing else may claim it.
25
+ const reTabOrder = /^\s*(?:user\s+)?sees?\s+tab\s+order\s*:?\s*$/i;
26
+
27
+ /** `[Ref]` with an optional trailing element type: `[Save] button`. */
28
+ const CELL = /^\s*\[([^\]]+)\]\s*([A-Za-z][A-Za-z-]*)?\s*$/;
29
+
30
+ export const focusPatterns: StepPattern[] = [
31
+ {
32
+ name: 'tab-order-assertion',
33
+ matcher: (step) => reTabOrder.test(step.text),
34
+ resolver: (step, context) => {
35
+ const table = step.dataTable;
36
+ // The parser silently DROPS a table with fewer than 2 total rows, so a
37
+ // missing header row and a missing table look identical here — both must
38
+ // be a loud compile error, or the scenario would pass asserting nothing.
39
+ if (!table) {
40
+ throw new Error(
41
+ `"${step.text}": tab order needs a data table with a header row and at least 2 element rows:\n` +
42
+ ' | Ref |\n | [First Element] |\n | [Second Element] |');
43
+ }
44
+ if (/^\s*\[/.test(table.headers[0] ?? '')) {
45
+ throw new Error(
46
+ `"${step.text}": the first table row looks like an element ("${table.headers[0]}") — it was consumed as the HEADER. ` +
47
+ 'Add a header row (`| Ref |`) so no element is silently swallowed.');
48
+ }
49
+ if (table.rows.length < 2) {
50
+ throw new Error(
51
+ `"${step.text}": a tab order needs at least 2 element rows — with one row there is nothing after the origin to assert, and the step would pass unconditionally.`);
52
+ }
53
+
54
+ const cells: Array<Record<string, unknown>> = [];
55
+ const errors: string[] = [];
56
+ table.rows.forEach((row, i) => {
57
+ const raw = row.cells[0] ?? '';
58
+ const m = raw.match(CELL);
59
+ if (!m) {
60
+ errors.push(`row ${i + 1} ("${raw}"): cell must be "[Ref]" with an optional element type, e.g. "[Save] button"`);
61
+ return;
62
+ }
63
+ try {
64
+ const resolved = context.selectorResolver.resolveSelector(m[1], context.featureName, m[2], 0);
65
+ cells.push({ ...resolved, label: m[1] });
66
+ } catch (e) {
67
+ errors.push(`row ${i + 1} [${m[1]}]: ${e instanceof Error ? e.message : String(e)}`);
68
+ }
69
+ });
70
+ if (errors.length > 0) {
71
+ throw new Error(
72
+ `"${step.text}": ${errors.length} tab-order cell(s) failed to resolve — fix selectors.yaml, no fallback is guessed:\n - ${errors.join('\n - ')}`);
73
+ }
74
+
75
+ return {
76
+ templateName: 'tab-order-assertion',
77
+ data: { cells, stepCounter: context.stepCounter },
78
+ comment: `Assert tab order: ${cells.map((c) => `[${c.label}]`).join(' → ')}`,
79
+ };
80
+ },
81
+ priority: 60,
82
+ },
83
+ ];
@@ -10,17 +10,27 @@ export const keyboardPatterns: StepPattern[] = [
10
10
  name: 'press-key-global',
11
11
  matcher: (step) => {
12
12
  const text = step.text.toLowerCase();
13
- return /\bpress(?:es)?\s+\w+\s+key\b/.test(text) && !text.includes(' on ');
13
+ // Matchers see bracket refs MASKED ([Enter] → [~]), so the bracketed key form
14
+ // the docs teach ("press [Enter] key") must be accepted as [~] — plain \w+ made
15
+ // it fall through to a // TODO no-op.
16
+ return /\bpress(?:es)?\s+(?:\w+|\[~\])\s+key\b/.test(text) && !text.includes(' on ');
14
17
  },
15
18
  resolver: (step, context) => {
16
- // Extract key name: "User press Escape key" → "Escape"
17
- const match = step.text.match(/press(?:es)?\s+(\w+)\s+key/i);
18
- const key = match ? match[1] : 'Enter';
19
+ // Resolver reads the ORIGINAL text: "press Escape key" / "press [Enter] key" → key name.
20
+ // A bracketed key is taken WHOLE — "[Arrow Down]" must not degrade to the 'Enter'
21
+ // default (a silently wrong key); spaces collapse to the Playwright spelling
22
+ // (ArrowDown), and anything else (chords like Control+A) passes through so an
23
+ // invalid name fails loudly at runtime instead of green-and-wrong at compile.
24
+ const match = step.text.match(/press(?:es)?\s+(?:\[([^\]]+)\]|(\w+))\s+key/i);
25
+ const key = match ? (match[1] ?? match[2]).trim().replace(/\s+/g, '') : 'Enter';
26
+ // "press Tab key 5 times" — without this the count silently compiled to ONE press.
27
+ const times = step.text.match(/\bkey\s+(\d+)\s+times?\b/i);
28
+ const repeat = times ? parseInt(times[1], 10) : 0;
19
29
 
20
30
  return {
21
31
  templateName: 'keyboard-global-action',
22
- data: { key },
23
- comment: `Press ${key} key`,
32
+ data: { key, repeat: repeat > 1 ? repeat : undefined },
33
+ comment: repeat > 1 ? `Press ${key} key ${repeat} times` : `Press ${key} key`,
24
34
  };
25
35
  },
26
36
  priority: 12,
@@ -1,6 +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
5
 
5
6
  /**
6
7
  * Navigation patterns: goto, navigate, open page
@@ -38,7 +39,7 @@ export const navigationPatterns: StepPattern[] = [
38
39
  // `[X] page with {{v}}` — the query/fragment value parsed into dataRef (issue #464).
39
40
  // Raw concat, no "smart" URL merging: the primary use case is injecting malformed
40
41
  // 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
+ // Runtime mode resolves to a __SUNGEN_TD$…$__ marker → `path + testData.get(...)`.
42
43
  let query = '';
43
44
  if (step.dataRef) {
44
45
  try {
@@ -53,9 +54,13 @@ export const navigationPatterns: StepPattern[] = [
53
54
  // separately (toHaveURL's regex stays compile-time escapable; the query value is
54
55
  // runtime data with regex-special characters).
55
56
  if (isThen) {
57
+ // Regex from the RAW path, not finalPath: resolvePathVariables rewrites `:id` into
58
+ // `${id}` for a goto template literal, which an assertion then escapes into
59
+ // `\/users\/\$\{id\}\/profile` — a regex no real URL can match. An assertion wants
60
+ // the wildcard the `see [X] page` twin already produced.
56
61
  return {
57
62
  templateName: 'route-assertion',
58
- data: { path: finalPath, query },
63
+ data: { pathRegex: pathToRegexSource(path), query },
59
64
  comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
60
65
  };
61
66
  }
@@ -115,9 +120,10 @@ export const navigationPatterns: StepPattern[] = [
115
120
  const isAbsoluteUrl = /^https?:\/\//.test(cleanPath);
116
121
 
117
122
  if (isThen) {
123
+ // Same rule as above — assert against the pre-resolution path so `:id` wildcards.
118
124
  return {
119
125
  templateName: 'route-assertion',
120
- data: { path: cleanPath },
126
+ data: { pathRegex: pathToRegexSource(inferredPath) },
121
127
  comment: `Assert on ${route}`,
122
128
  };
123
129
  }
@@ -142,12 +148,9 @@ export const navigationPatterns: StepPattern[] = [
142
148
  featureName: context.featureName,
143
149
  screenName: context.screenName,
144
150
  });
145
- const resolvedPath = resolvePathVariables(inferredPath, context.scenarioSteps || []);
146
- const pathCode = getPathCode(resolvedPath);
147
-
148
151
  return {
149
152
  templateName: 'route-assertion',
150
- data: { path: pathCode.replace(/^['`]|['`]$/g, '') },
153
+ data: { pathRegex: pathToRegexSource(inferredPath) },
151
154
  comment: `Assert current route is ${route}`,
152
155
  };
153
156
  },
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Browser-storage assertion patterns — read-only checks against
3
+ * localStorage/sessionStorage:
4
+ *
5
+ * Then User see [KEY] in local storage exists
6
+ * Then User see [KEY] in local storage does not exist
7
+ * Then User see [KEY] in local storage is not empty
8
+ * Then User see [KEY] in local storage has length {{n}}
9
+ * Then User see key matching "PATTERN" in local storage
10
+ * (+ the same five with `session storage`)
11
+ *
12
+ * Leak-safe by construction: the check runs INSIDE the browser and only a
13
+ * boolean crosses back — a failed assertion's Expected:/Received: shows
14
+ * true/false, never the stored value (tokens routinely live in these keys,
15
+ * and failure text is lifted verbatim into committed deliverables).
16
+ *
17
+ * `[KEY]` is NOT a selector reference — resolveSelector is never called.
18
+ * The bracket content is parsed here; an embedded `{{var}}` compiles to a
19
+ * testData.get() concatenation directly (same approach as the DB patterns'
20
+ * value binding), so `[{{code}}_ACCESS_TOKEN]` works without the marker
21
+ * pipeline. The parser stamps elementType="in" (first word after the
22
+ * bracket) — meaningless here, deliberately ignored.
23
+ */
24
+
25
+ import { StepPattern } from '@sun-asterisk/sungen';
26
+
27
+ // Matchers see ref contents MASKED ([AUTH_TOKEN] → [~], {{n}} → {{~}}), so they
28
+ // anchor on the discriminator OUTSIDE the brackets. Anchored + priority 60 —
29
+ // the generic `should-be-visible` catch-all (priority 10, any `see [ref]`)
30
+ // otherwise claims every bracketed shape and asserts a DOM element instead.
31
+ const AREA = String.raw`(?:local|session)\s+storage`;
32
+ const reExists = new RegExp(String.raw`\bsees?\s+\[~\]\s+in\s+${AREA}\s+exists\b`, 'i');
33
+ const reNotExist = new RegExp(String.raw`\bsees?\s+\[~\]\s+in\s+${AREA}\s+does\s+not\s+exist\b`, 'i');
34
+ const reNotEmpty = new RegExp(String.raw`\bsees?\s+\[~\]\s+in\s+${AREA}\s+is\s+not\s+empty\b`, 'i');
35
+ const reHasLength = new RegExp(String.raw`\bsees?\s+\[~\]\s+in\s+${AREA}\s+has\s+length\s+(?:\{\{~\}\}|\d+)`, 'i');
36
+ // The one shape with no bracket: the quoted pattern survives masking untouched.
37
+ const reKeyMatching = new RegExp(String.raw`\bsees?\s+key\s+matching\s+"[^"]+"\s+in\s+${AREA}\b`, 'i');
38
+
39
+ /**
40
+ * `localStorage` | `sessionStorage` from the step's STRUCTURAL text — bracket keys and
41
+ * quoted patterns are neutralized first, so a key named "user session storage flag"
42
+ * (or a pattern containing the words) can never flip the area of a `local storage` step.
43
+ */
44
+ function storageArea(text: string): string {
45
+ const structural = text.replace(/\[[^\]]*\]/g, '[~]').replace(/"[^"]*"/g, '""');
46
+ return /\bin\s+session\s+storage\b/i.test(structural) ? 'sessionStorage' : 'localStorage';
47
+ }
48
+
49
+ /** 'local storage' | 'session storage' — for comments/messages. */
50
+ function areaLabel(text: string): string {
51
+ return storageArea(text) === 'sessionStorage' ? 'session storage' : 'local storage';
52
+ }
53
+
54
+ /**
55
+ * Bracket content → JS string expression. A plain key becomes a quoted literal;
56
+ * embedded `{{var}}` refs become `${testData.get('var')}` interpolations in a
57
+ * template literal (direct concatenation — no marker round-trip).
58
+ */
59
+ function keyExpr(raw: string): string {
60
+ if (!/\{\{[^}]+\}\}/.test(raw)) return JSON.stringify(raw);
61
+ // `$` is escaped too: a literal `${` in the key would otherwise become a LIVE
62
+ // interpolation in the emitted template literal. The {{var}} substitution below
63
+ // inserts its own `${testData.get(...)}` afterwards, so it is unaffected.
64
+ const body = raw
65
+ .replace(/[\\`$]/g, '\\$&')
66
+ .replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, name) => `\${testData.get(${JSON.stringify(name)})}`);
67
+ return `\`${body}\``;
68
+ }
69
+
70
+ /** The bracketed key from the ORIGINAL step text (masking never reaches resolvers). */
71
+ function bracketKey(text: string): string {
72
+ const m = text.match(/sees?\s+\[([^\]]+)\]\s+in/i);
73
+ return m ? m[1] : '';
74
+ }
75
+
76
+ interface StorageCheck {
77
+ /** In-browser predicate over ([k, arg]) — must yield a boolean, never a value. */
78
+ checkExpr: string;
79
+ /** Second evaluate argument (JS expression), 'null' when unused. */
80
+ argExpr: string;
81
+ /** Human suffix for the assertion message ("expected to exist"). */
82
+ expectation: string;
83
+ }
84
+
85
+ function buildCheck(text: string, area: string): StorageCheck | null {
86
+ if (/\bdoes\s+not\s+exist\b/i.test(text)) {
87
+ return { checkExpr: `window.${area}.getItem(k) === null`, argExpr: 'null', expectation: 'expected to be absent' };
88
+ }
89
+ if (/\bis\s+not\s+empty\b/i.test(text)) {
90
+ return { checkExpr: `!!window.${area}.getItem(k)`, argExpr: 'null', expectation: 'expected a non-empty value' };
91
+ }
92
+ const len = text.match(/\bhas\s+length\s+(\{\{[^}]+\}\}|\d+)/i);
93
+ if (len) {
94
+ const v = len[1].match(/^\{\{\s*([^}]+?)\s*\}\}$/);
95
+ const argExpr = v ? `Number(testData.get(${JSON.stringify(v[1])}))` : len[1];
96
+ return { checkExpr: `(window.${area}.getItem(k) ?? '').length === arg`, argExpr, expectation: 'expected the given length' };
97
+ }
98
+ if (/\bexists\b/i.test(text)) {
99
+ return { checkExpr: `window.${area}.getItem(k) !== null`, argExpr: 'null', expectation: 'expected to exist' };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export const storagePatterns: StepPattern[] = [
105
+ {
106
+ name: 'storage-key-assertion',
107
+ matcher: (step) =>
108
+ reExists.test(step.text) || reNotExist.test(step.text) ||
109
+ reNotEmpty.test(step.text) || reHasLength.test(step.text),
110
+ resolver: (step) => {
111
+ const area = storageArea(step.text);
112
+ const check = buildCheck(step.text, area);
113
+ const rawKey = bracketKey(step.text);
114
+ // Matcher anchoring guarantees both; guard for the type system.
115
+ if (!check || !rawKey) throw new Error(`unrecognized storage assertion: ${step.text}`);
116
+ const k = keyExpr(rawKey);
117
+ return {
118
+ templateName: 'storage-key-assertion',
119
+ data: {
120
+ checkExpr: check.checkExpr,
121
+ keyExpr: k,
122
+ argExpr: check.argExpr,
123
+ // Key NAME is printable; the stored VALUE never crosses this boundary.
124
+ messageExpr: `'key ' + ${k} + ' in ${areaLabel(step.text)}: ${check.expectation}'`,
125
+ },
126
+ comment: `Assert ${rawKey} in ${areaLabel(step.text)} (${check.expectation})`,
127
+ };
128
+ },
129
+ priority: 60,
130
+ },
131
+ {
132
+ name: 'storage-key-matching-assertion',
133
+ matcher: (step) => reKeyMatching.test(step.text),
134
+ resolver: (step) => {
135
+ const area = storageArea(step.text);
136
+ const m = step.text.match(/key\s+matching\s+"([^"]+)"/i);
137
+ const pattern = m ? m[1] : '';
138
+ return {
139
+ templateName: 'storage-key-assertion',
140
+ data: {
141
+ checkExpr: `Object.keys(window.${area}).some((key) => new RegExp(k).test(key))`,
142
+ keyExpr: JSON.stringify(pattern),
143
+ argExpr: 'null',
144
+ messageExpr: `'key matching ' + ${JSON.stringify(JSON.stringify(pattern))} + ' in ${areaLabel(step.text)}: expected a match'`,
145
+ },
146
+ comment: `Assert a ${areaLabel(step.text)} key matches "${pattern}"`,
147
+ };
148
+ },
149
+ priority: 60,
150
+ },
151
+ ];