@sungen/driver-ui 3.2.18 → 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
+
];
|