@sungen/driver-ui 3.2.18 → 3.2.20-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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.20-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.20-beta.1"
|
|
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,27 @@ 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
|
|
124
|
-
|
|
137
|
+
// `dataRef` rides along so a runtime failure can name the test-data key an author must go
|
|
138
|
+
// fix — an empty/absent value is otherwise the hardest kind of red to trace.
|
|
139
|
+
data: { pathRegex, query, dataRef: step.dataRef || '' },
|
|
140
|
+
comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
|
|
125
141
|
};
|
|
126
142
|
},
|
|
127
143
|
priority: 13, // Higher priority than general visibility
|
|
@@ -473,11 +489,16 @@ export const assertionPatterns: StepPattern[] = [
|
|
|
473
489
|
// list-member roles only (listitem/option/cell) — unique labelled elements keep
|
|
474
490
|
// toHaveText (value check), and locator-type lists are unchanged. `row` is excluded
|
|
475
491
|
// (table rows have dedicated existence patterns).
|
|
476
|
-
|
|
492
|
+
// The name is NOT stripped here any more. It used to be dropped unconditionally, which also
|
|
493
|
+
// discarded a `name:` the user had declared explicitly — and the strip only ever protected
|
|
494
|
+
// THIS branch, leaving `is hidden` / `is disabled` to compile the invented name into a locator
|
|
495
|
+
// that matches nothing. The resolver now withholds the invented name at its source
|
|
496
|
+
// (LIST_MEMBER_ROLES in selector-resolver), so every branch is covered and an explicit name
|
|
497
|
+
// still reaches the locator. What stays here is the ROUTING: filter-then-visible instead of
|
|
498
|
+
// toHaveText, because a bare getByRole('listitem') strict-mode-fails when many match.
|
|
477
499
|
if (resolved.strategy === 'role' && resolved.role && LIST_MEMBER_ROLES.has(resolved.role)) {
|
|
478
|
-
const { name: _name, ...resolvedWithoutName } = resolved;
|
|
479
500
|
const code = context.templateEngine.renderStep('visible-filtered-assertion', {
|
|
480
|
-
...
|
|
501
|
+
...resolved,
|
|
481
502
|
dataValue,
|
|
482
503
|
});
|
|
483
504
|
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
|
-
|
|
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
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
@@ -26,7 +27,13 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
26
27
|
const resolved = context.selectorResolver.resolveSelector(
|
|
27
28
|
step.selectorRef, context.featureName, step.elementType, step.nth
|
|
28
29
|
);
|
|
29
|
-
|
|
30
|
+
// Only a `page`-type entry carries a URL in `value` — the same guard the `see [X] page`
|
|
31
|
+
// twin has carried since the key-collision fix (assertion-patterns.ts). Without it a
|
|
32
|
+
// colliding non-page entry (a `type: role` whose value is 'button') was used verbatim as
|
|
33
|
+
// the path, so the step asserted `^button/?$` — a wrong, specific path rather than the
|
|
34
|
+
// documented fallback. These two resolvers have drifted apart twice already; keep them
|
|
35
|
+
// spelling the same rule.
|
|
36
|
+
path = resolved.selectorType === 'page' ? (resolved.value || pathFallback) : pathFallback;
|
|
30
37
|
} catch (error) {
|
|
31
38
|
path = pathFallback;
|
|
32
39
|
}
|
|
@@ -38,7 +45,7 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
38
45
|
// `[X] page with {{v}}` — the query/fragment value parsed into dataRef (issue #464).
|
|
39
46
|
// Raw concat, no "smart" URL merging: the primary use case is injecting malformed
|
|
40
47
|
// query input, so the compiler must not sanitize what the test wants broken.
|
|
41
|
-
// Runtime mode resolves to a
|
|
48
|
+
// Runtime mode resolves to a __SUNGEN_TD$…$__ marker → `path + testData.get(...)`.
|
|
42
49
|
let query = '';
|
|
43
50
|
if (step.dataRef) {
|
|
44
51
|
try {
|
|
@@ -53,16 +60,25 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
53
60
|
// separately (toHaveURL's regex stays compile-time escapable; the query value is
|
|
54
61
|
// runtime data with regex-special characters).
|
|
55
62
|
if (isThen) {
|
|
63
|
+
// Regex from the RAW path, not finalPath: resolvePathVariables rewrites `:id` into
|
|
64
|
+
// `${id}` for a goto template literal, which an assertion then escapes into
|
|
65
|
+
// `\/users\/\$\{id\}\/profile` — a regex no real URL can match. An assertion wants
|
|
66
|
+
// the wildcard the `see [X] page` twin already produced.
|
|
56
67
|
return {
|
|
57
68
|
templateName: 'route-assertion',
|
|
58
|
-
data
|
|
69
|
+
// `dataRef` rides along so a runtime failure names the test-data key (see the `see` twin).
|
|
70
|
+
data: { pathRegex: pathToRegexSource(path), query, dataRef: step.dataRef || '' },
|
|
59
71
|
comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
|
|
60
72
|
};
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
return {
|
|
64
76
|
templateName: 'navigation',
|
|
65
|
-
|
|
77
|
+
// `query` stays SEPARATE from `path` rather than concatenated here: in runtime-data mode
|
|
78
|
+
// the value is still a `__SUNGEN_TD$…$__` marker at this point, so the compiler cannot see
|
|
79
|
+
// whether it carries its own path. Deciding that is therefore a runtime job — the template
|
|
80
|
+
// hands both parts to joinPath(). Concatenating here is what produced `/vi/search/vi/search`.
|
|
81
|
+
data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath, query },
|
|
66
82
|
comment: step.selectorRef ? `Open ${step.selectorRef} page${withSuffix}` : `Navigate to page`,
|
|
67
83
|
};
|
|
68
84
|
},
|
|
@@ -115,9 +131,10 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
115
131
|
const isAbsoluteUrl = /^https?:\/\//.test(cleanPath);
|
|
116
132
|
|
|
117
133
|
if (isThen) {
|
|
134
|
+
// Same rule as above — assert against the pre-resolution path so `:id` wildcards.
|
|
118
135
|
return {
|
|
119
136
|
templateName: 'route-assertion',
|
|
120
|
-
data: {
|
|
137
|
+
data: { pathRegex: pathToRegexSource(inferredPath) },
|
|
121
138
|
comment: `Assert on ${route}`,
|
|
122
139
|
};
|
|
123
140
|
}
|
|
@@ -142,12 +159,9 @@ export const navigationPatterns: StepPattern[] = [
|
|
|
142
159
|
featureName: context.featureName,
|
|
143
160
|
screenName: context.screenName,
|
|
144
161
|
});
|
|
145
|
-
const resolvedPath = resolvePathVariables(inferredPath, context.scenarioSteps || []);
|
|
146
|
-
const pathCode = getPathCode(resolvedPath);
|
|
147
|
-
|
|
148
162
|
return {
|
|
149
163
|
templateName: 'route-assertion',
|
|
150
|
-
data: {
|
|
164
|
+
data: { pathRegex: pathToRegexSource(inferredPath) },
|
|
151
165
|
comment: `Assert current route is ${route}`,
|
|
152
166
|
};
|
|
153
167
|
},
|
|
@@ -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
|
+
];
|