@sungen/driver-ui 3.2.34 → 3.3.0-alpha.2

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.
Files changed (89) hide show
  1. package/dist/app-profile-presets.d.ts +12 -0
  2. package/dist/app-profile-presets.d.ts.map +1 -0
  3. package/dist/app-profile-presets.js +85 -0
  4. package/dist/app-profile-presets.js.map +1 -0
  5. package/dist/index.d.ts +37 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +93 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/patterns/assertion-patterns.d.ts +13 -0
  10. package/dist/patterns/assertion-patterns.d.ts.map +1 -0
  11. package/dist/patterns/assertion-patterns.js +903 -0
  12. package/dist/patterns/assertion-patterns.js.map +1 -0
  13. package/dist/patterns/capture-patterns.d.ts +21 -0
  14. package/dist/patterns/capture-patterns.d.ts.map +1 -0
  15. package/dist/patterns/capture-patterns.js +174 -0
  16. package/dist/patterns/capture-patterns.js.map +1 -0
  17. package/dist/patterns/focus-patterns.d.ts +22 -0
  18. package/dist/patterns/focus-patterns.d.ts.map +1 -0
  19. package/dist/patterns/focus-patterns.js +79 -0
  20. package/dist/patterns/focus-patterns.js.map +1 -0
  21. package/dist/patterns/form-patterns.d.ts +6 -0
  22. package/dist/patterns/form-patterns.d.ts.map +1 -0
  23. package/dist/patterns/form-patterns.js +239 -0
  24. package/dist/patterns/form-patterns.js.map +1 -0
  25. package/dist/patterns/interaction-patterns.d.ts +20 -0
  26. package/dist/patterns/interaction-patterns.d.ts.map +1 -0
  27. package/dist/patterns/interaction-patterns.js +633 -0
  28. package/dist/patterns/interaction-patterns.js.map +1 -0
  29. package/dist/patterns/keyboard-patterns.d.ts +7 -0
  30. package/dist/patterns/keyboard-patterns.d.ts.map +1 -0
  31. package/dist/patterns/keyboard-patterns.js +57 -0
  32. package/dist/patterns/keyboard-patterns.js.map +1 -0
  33. package/dist/patterns/navigation-patterns.d.ts +6 -0
  34. package/dist/patterns/navigation-patterns.d.ts.map +1 -0
  35. package/dist/patterns/navigation-patterns.js +185 -0
  36. package/dist/patterns/navigation-patterns.js.map +1 -0
  37. package/dist/patterns/scope-patterns.d.ts +7 -0
  38. package/dist/patterns/scope-patterns.d.ts.map +1 -0
  39. package/dist/patterns/scope-patterns.js +36 -0
  40. package/dist/patterns/scope-patterns.js.map +1 -0
  41. package/dist/patterns/scroll-patterns.d.ts +7 -0
  42. package/dist/patterns/scroll-patterns.d.ts.map +1 -0
  43. package/dist/patterns/scroll-patterns.js +25 -0
  44. package/dist/patterns/scroll-patterns.js.map +1 -0
  45. package/dist/patterns/setup-patterns.d.ts +6 -0
  46. package/dist/patterns/setup-patterns.d.ts.map +1 -0
  47. package/dist/patterns/setup-patterns.js +75 -0
  48. package/dist/patterns/setup-patterns.js.map +1 -0
  49. package/dist/patterns/storage-patterns.d.ts +26 -0
  50. package/dist/patterns/storage-patterns.d.ts.map +1 -0
  51. package/dist/patterns/storage-patterns.js +142 -0
  52. package/dist/patterns/storage-patterns.js.map +1 -0
  53. package/dist/patterns/table-pagination-patterns.d.ts +42 -0
  54. package/dist/patterns/table-pagination-patterns.d.ts.map +1 -0
  55. package/dist/patterns/table-pagination-patterns.js +93 -0
  56. package/dist/patterns/table-pagination-patterns.js.map +1 -0
  57. package/dist/patterns/table-patterns.d.ts +19 -0
  58. package/dist/patterns/table-patterns.d.ts.map +1 -0
  59. package/dist/patterns/table-patterns.js +594 -0
  60. package/dist/patterns/table-patterns.js.map +1 -0
  61. package/dist/trace-identity/aria-snapshot-head.d.ts +23 -0
  62. package/dist/trace-identity/aria-snapshot-head.d.ts.map +1 -0
  63. package/dist/trace-identity/aria-snapshot-head.js +26 -0
  64. package/dist/trace-identity/aria-snapshot-head.js.map +1 -0
  65. package/dist/trace-identity/playwright-identity-resolver.d.ts +10 -0
  66. package/dist/trace-identity/playwright-identity-resolver.d.ts.map +1 -0
  67. package/dist/trace-identity/playwright-identity-resolver.js +130 -0
  68. package/dist/trace-identity/playwright-identity-resolver.js.map +1 -0
  69. package/dist/trace-identity/trace-run-record-reporter.d.ts +41 -0
  70. package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
  71. package/dist/trace-identity/trace-run-record-reporter.js +113 -0
  72. package/dist/trace-identity/trace-run-record-reporter.js.map +1 -0
  73. package/dist/utils/column-header.d.ts +37 -0
  74. package/dist/utils/column-header.d.ts.map +1 -0
  75. package/dist/utils/column-header.js +46 -0
  76. package/dist/utils/column-header.js.map +1 -0
  77. package/dist/utils/row-index.d.ts +18 -0
  78. package/dist/utils/row-index.d.ts.map +1 -0
  79. package/dist/utils/row-index.js +39 -0
  80. package/dist/utils/row-index.js.map +1 -0
  81. package/dist/utils/url-path-regex.d.ts +45 -0
  82. package/dist/utils/url-path-regex.d.ts.map +1 -0
  83. package/dist/utils/url-path-regex.js +68 -0
  84. package/dist/utils/url-path-regex.js.map +1 -0
  85. package/package.json +7 -3
  86. package/src/index.ts +10 -1
  87. package/src/trace-identity/aria-snapshot-head.ts +33 -0
  88. package/src/trace-identity/playwright-identity-resolver.ts +123 -0
  89. package/src/trace-identity/trace-run-record-reporter.ts +105 -0
@@ -0,0 +1,903 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertionPatterns = void 0;
4
+ const sungen_1 = require("@sun-asterisk/sungen");
5
+ const sungen_2 = require("@sun-asterisk/sungen");
6
+ const sungen_3 = require("@sun-asterisk/sungen");
7
+ const url_path_regex_1 = require("../utils/url-path-regex");
8
+ const column_header_1 = require("../utils/column-header");
9
+ /**
10
+ * Assertion patterns: visibility, text content, state, attributes
11
+ * Uses template engine for framework-agnostic code generation
12
+ */
13
+ /** State qualifiers that may follow `with {{value}}` and need value-filtered handling
14
+ * (see-with-variable-state). `is disabled`/`is hidden` are EXCLUDED — they keep their
15
+ * dedicated -disabled/-hidden twins. `is unchecked` is tested before `is checked` so
16
+ * the substring never misclassifies. `selected` probes at runtime (aria-selected vs
17
+ * checked) exactly like selected-assertion. */
18
+ function qualifiedState(text) {
19
+ if (/\bis enabled\b/.test(text))
20
+ return { assertion: 'toBeEnabled()', label: 'enabled' };
21
+ if (/\bis unchecked\b/.test(text))
22
+ return { assertion: 'toBeChecked({ checked: false })', label: 'unchecked' };
23
+ if (/\bis checked\b/.test(text))
24
+ return { assertion: 'toBeChecked()', label: 'checked' };
25
+ if (/\bis focused\b/.test(text))
26
+ return { assertion: 'toBeFocused()', label: 'focused' };
27
+ if (/\bis empty\b/.test(text))
28
+ return { assertion: 'toBeEmpty()', label: 'empty' };
29
+ if (/\bis selected\b/.test(text))
30
+ return { assertion: '', label: 'selected', probe: true };
31
+ return null;
32
+ }
33
+ /** Locator-strategy element-type tokens that name a LIST MEMBER (many-matching by design —
34
+ * custom widgets with no role, e.g. a 96-slot time picker). Text assertions on these must
35
+ * filter the multi-locator by the value first: a bare toHaveText/toContainText on the raw
36
+ * locator strict-mode-fails. The role-strategy counterpart is LIST_MEMBER_ROLES. */
37
+ const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
38
+ /** Resolved strategies that pin a node by IDENTITY rather than by the text it renders. A
39
+ * `see [X] label with {{v}}` step honours these as real locators and asserts their text (#666).
40
+ *
41
+ * The omissions are deliberate, not an oversight: `text` finds the node by the very caption the
42
+ * label pattern is about, so honouring it would assert the caption contains the value; `label`
43
+ * (getByLabel) and `placeholder` resolve the labelled CONTROL, whose content is a `value`, not
44
+ * text; `role` names an element through its accessible name, which for a caption IS the caption.
45
+ * Each of those keeps the caption+value text reading. */
46
+ const IDENTITY_STRATEGIES = new Set([
47
+ 'testid', 'id', 'css', 'locator', 'xpath',
48
+ 'accessibility-id', 'android-uiautomator', 'ios-predicate',
49
+ ]);
50
+ /**
51
+ * INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
52
+ * MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
53
+ * `throw`; over-declared, the clause is silently dropped and the assertion reads as something it is
54
+ * not. Determine the truth from the TEMPLATE the resolver names, never from the matcher.
55
+ *
56
+ * Deliberately NOT declared, because each really does drop the clause today: `page-assertion`
57
+ * (a page has no state — this was the original defect), `wait-for-page`, `table-row-exists`,
58
+ * `table-column-exists`, `see-with-variable`, `alert-text-assertion`, `all-contain-element`.
59
+ */
60
+ exports.assertionPatterns = [
61
+ // Browser alert text assertion: "see [Are you sure?] alert"
62
+ {
63
+ name: 'alert-text-assertion',
64
+ matcher: (step) => (step.text.includes('see') || step.text.includes('sees')) &&
65
+ step.elementType === 'alert' &&
66
+ !!step.selectorRef,
67
+ resolver: (step, context) => {
68
+ let dataValue = step.selectorRef || '';
69
+ if (step.dataRef) {
70
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
71
+ }
72
+ return {
73
+ templateName: 'alert-text-assertion',
74
+ data: { dataValue, stepCounter: context.stepCounter },
75
+ comment: `Assert browser alert contains "${step.selectorRef}"`,
76
+ };
77
+ },
78
+ priority: 21,
79
+ },
80
+ // Column cell assertion: "see [Department] column 1 with {{value}}" -> check table cell text
81
+ {
82
+ name: 'column-cell-assertion',
83
+ platforms: ['web'],
84
+ platformAlternative: 'HTML table roles have no native equivalent - assert the row element itself by accessibility-id (`Then User see [Row 2 Amount] has text {{v}}`), or tag the scenario @manual',
85
+ matcher: (step) => (step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
86
+ !!step.selectorRef &&
87
+ !!step.dataRef &&
88
+ (step.elementType === 'column' || step.elementType === 'columnheader'),
89
+ resolver: (step, context) => {
90
+ let dataValue;
91
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
92
+ // getByRole('row') includes header row as nth(0), so Gherkin nth=1 maps to locator nth(1)
93
+ const rowNth = step.nth || 1;
94
+ // Create a safe variable name from column name
95
+ const columnIndexVar = `colIndex${step.selectorRef.replace(/[^a-zA-Z0-9]/g, '')}`;
96
+ // The header TEXT this template matches on — both the visibility assertion and the
97
+ // findIndex scan — is declarable; the bracket label is only the fallback (issue #674).
98
+ // No table is named by this step form, so only the `<col>--columnheader` entry applies.
99
+ const columnName = (0, column_header_1.resolveColumnHeaderName)(step.selectorRef, context);
100
+ return {
101
+ templateName: 'column-cell-assertion',
102
+ data: {
103
+ columnName,
104
+ rowNth,
105
+ columnIndexVar,
106
+ dataValue,
107
+ },
108
+ comment: `Assert ${step.selectorRef} column row ${step.nth || 1} has text ${step.dataRef}`,
109
+ };
110
+ },
111
+ priority: 15, // Higher than all other assertion patterns
112
+ },
113
+ // Page assertion pattern: "see [home] page" -> check URL matches selector value
114
+ {
115
+ name: 'page-assertion',
116
+ matcher: (step) => (step.text.includes('should see') ||
117
+ step.text.includes('see') ||
118
+ step.text.includes('sees')) &&
119
+ step.elementType === 'page',
120
+ resolver: (step, context) => {
121
+ // Fallback is the feature's declared Path (context.featurePath) — the page under test.
122
+ // `step.featurePath` is never populated by the parser, so it always degraded to '/'.
123
+ const fallbackPath = context.featurePath || '/';
124
+ let path = fallbackPath;
125
+ // The page's declared `anchor:` — the element that proves the page rendered. Without it the
126
+ // step is a URL-only oracle, which a client-side router can satisfy for a few hundred
127
+ // milliseconds before a guard bounces the user away (#662).
128
+ let anchor;
129
+ // The page entry's `query: any` (#748) — set below, once the entry is known to BE a page.
130
+ let ignoreQuery = false;
131
+ // If selector is present, extract path from selector's value attribute
132
+ if (step.selectorRef) {
133
+ try {
134
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
135
+ // Only a `page`-type entry carries a URL in `value`. Selector keys can collide across
136
+ // types (a single `読取結果一覧` entry of type `role` reused for a page assertion), and a
137
+ // key-only lookup happily returns that button entry — whose `value` ('button') is not a
138
+ // URL. Guard on the entry kind: use `value` only for a page selector, else fall back to
139
+ // the feature path rather than compiling `toHaveURL(/button/)`.
140
+ path = resolved.selectorType === 'page' ? (resolved.value || fallbackPath) : fallbackPath;
141
+ if (resolved.selectorType === 'page' && resolved.anchor)
142
+ anchor = { ...resolved.anchor };
143
+ // `query: any` — the query is not part of this page's identity (#748). Gated on the entry
144
+ // KIND for the same reason `value` and `anchor` are: a key collision can hand back a
145
+ // non-page entry, and reading a stray field off it would silently weaken the assertion.
146
+ if (resolved.selectorType === 'page' && resolved.query === 'any')
147
+ ignoreQuery = true;
148
+ }
149
+ catch (error) {
150
+ // Fallback to feature path if selector not found
151
+ path = fallbackPath;
152
+ }
153
+ }
154
+ // `see [X] page with {{v}}` — the query/fragment value the parser put in dataRef. It used
155
+ // to be dropped here: the step compiled to a bare base-path toHaveURL that PASSES even when
156
+ // the query is wrong or gone, so a filter/search regression read as green. The `is on` twin
157
+ // has asserted it since issue #464; this is the same assertion, so it asserts the same way.
158
+ let query = '';
159
+ if (step.dataRef) {
160
+ query = context.dataResolver.resolveData(step.dataRef, context.featureName);
161
+ }
162
+ // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard). An ABSOLUTE
163
+ // page value also yields a host pattern: matching only the path let this assertion pass on
164
+ // any host, so `see [Admin Jobs] page` declared as https://admin.example.com/jobs was
165
+ // satisfied by the public site's /jobs.
166
+ const { pathRegex, hostRegex } = (0, url_path_regex_1.urlToPatternParts)(path);
167
+ const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
168
+ return {
169
+ templateName: 'page-assertion',
170
+ // `dataRef` rides along so a runtime failure can name the test-data key an author must go
171
+ // fix — an empty/absent value is otherwise the hardest kind of red to trace.
172
+ data: { pathRegex, hostRegex, query, dataRef: step.dataRef || '', anchor, ignoreQuery },
173
+ comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
174
+ };
175
+ },
176
+ priority: 13, // Higher priority than general visibility
177
+ },
178
+ // Pattern: Then User see [panel] dialog with {{kudo.title}} hidden -> toBeHidden()
179
+ {
180
+ name: 'see-with-variable-hidden',
181
+ states: ['hidden'],
182
+ matcher: (step) => (step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
183
+ !!step.selectorRef &&
184
+ !!step.dataRef &&
185
+ step.text.includes('with') &&
186
+ /\bis hidden\b/.test(step.text),
187
+ generator: (step, context) => {
188
+ let resolved = {};
189
+ try {
190
+ resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
191
+ }
192
+ catch (error) { }
193
+ let dataValue;
194
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
195
+ const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
196
+ if (resolved.strategy === 'locator') {
197
+ const code = context.templateEngine.renderStep('hidden-with-filter-assertion', {
198
+ ...resolved, dataValue, nth: resolved.nth,
199
+ });
200
+ return { code, comment: `Assert ${step.selectorRef} hidden with ${step.dataRef}` };
201
+ }
202
+ if (resolved.strategy === 'role' && resolved.role) {
203
+ // Dialog role: check heading inside dialog instead of filter on full text content
204
+ if (resolved.role === 'dialog') {
205
+ const code = context.templateEngine.renderStep('hidden-dialog-heading-assertion', {
206
+ dataValue,
207
+ });
208
+ return { code, comment: `Assert ${step.selectorRef} dialog hidden with heading ${step.dataRef}` };
209
+ }
210
+ const code = context.templateEngine.renderStep('hidden-with-role-variable-assertion', {
211
+ role: resolved.role,
212
+ name: resolved.name && resolved.name.trim() ? resolved.name : undefined,
213
+ dataValue,
214
+ nth: resolved.nth,
215
+ });
216
+ return { code, comment: `Assert ${step.selectorRef} hidden with ${step.dataRef}` };
217
+ }
218
+ const selectorValue = resolved.value || '';
219
+ if (selectorValue && selectorValue.trim()) {
220
+ const code = context.templateEngine.renderStep('hidden-with-filter-assertion', {
221
+ ...resolved, dataValue, nth: resolved.nth,
222
+ });
223
+ return { code, comment: `Assert ${step.selectorRef} hidden with ${step.dataRef}` };
224
+ }
225
+ const code = context.templateEngine.renderStep('hidden-with-variable-assertion', {
226
+ selectorRef: step.selectorRef,
227
+ selectorValue: resolved.value || '',
228
+ dataValue,
229
+ nth: resolved.nth,
230
+ });
231
+ return { code, comment: `Assert ${step.selectorRef} hidden with ${step.dataRef}` };
232
+ },
233
+ priority: 14,
234
+ },
235
+ // Pattern: Then User see [submit] button with {{label}} disabled -> toBeDisabled()
236
+ {
237
+ name: 'see-with-variable-disabled',
238
+ states: ['disabled'],
239
+ matcher: (step) => (step.text.includes('should see') || step.text.match(/\b(see|sees)\s+\[/)) &&
240
+ !!step.selectorRef &&
241
+ !!step.dataRef &&
242
+ step.text.includes('with') &&
243
+ /\bis disabled\b/.test(step.text),
244
+ generator: (step, context) => {
245
+ let resolved = {};
246
+ try {
247
+ resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
248
+ }
249
+ catch (error) { }
250
+ let dataValue;
251
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
252
+ const escapedVariable = dataValue.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
253
+ if (resolved.strategy === 'locator') {
254
+ const code = context.templateEngine.renderStep('disabled-with-filter-assertion', {
255
+ ...resolved, dataValue, nth: resolved.nth,
256
+ });
257
+ return { code, comment: `Assert ${step.selectorRef} disabled with ${step.dataRef}` };
258
+ }
259
+ if (resolved.strategy === 'role' && resolved.role) {
260
+ const code = context.templateEngine.renderStep('disabled-with-role-variable-assertion', {
261
+ role: resolved.role,
262
+ name: resolved.name && resolved.name.trim() ? resolved.name : undefined,
263
+ dataValue,
264
+ nth: resolved.nth,
265
+ });
266
+ return { code, comment: `Assert ${step.selectorRef} disabled with ${step.dataRef}` };
267
+ }
268
+ const selectorValue = resolved.value || '';
269
+ if (selectorValue && selectorValue.trim()) {
270
+ const code = context.templateEngine.renderStep('disabled-with-filter-assertion', {
271
+ ...resolved, dataValue, nth: resolved.nth,
272
+ });
273
+ return { code, comment: `Assert ${step.selectorRef} disabled with ${step.dataRef}` };
274
+ }
275
+ const code = context.templateEngine.renderStep('disabled-with-variable-assertion', {
276
+ selectorRef: step.selectorRef,
277
+ selectorValue: resolved.value || '',
278
+ dataValue,
279
+ nth: resolved.nth,
280
+ });
281
+ return { code, comment: `Assert ${step.selectorRef} disabled with ${step.dataRef}` };
282
+ },
283
+ priority: 14,
284
+ },
285
+ // Pattern: Then User see [Time Slot] option with {{time}} is enabled|selected|checked|…
286
+ // The value-qualified STATE family. Mirrors see-with-variable-hidden/-disabled (both p14):
287
+ // without this twin the generic see-with-variable (p13) swallowed the step and emitted a
288
+ // bare toHaveText — the state qualifier was silently DROPPED, and on a multi-match
289
+ // `type: locator` selector (e.g. a 96-slot time picker) the unfiltered locator also
290
+ // strict-mode-failed. Filter by the EXACT value first, then assert the state.
291
+ // `is disabled` / `is hidden` keep their existing dedicated twins — not matched here.
292
+ {
293
+ name: 'see-with-variable-state',
294
+ // Exactly the labels `qualifiedState()` above resolves — `hidden`/`disabled` are excluded
295
+ // there and owned by the -hidden/-disabled twins.
296
+ states: ['checked', 'empty', 'enabled', 'focused', 'selected', 'unchecked'],
297
+ matcher: (step) => (step.text.includes('should see') || !!step.text.match(/\b(see|sees)\s+\[/)) &&
298
+ !!step.selectorRef &&
299
+ !!step.dataRef &&
300
+ step.text.includes('with') &&
301
+ qualifiedState(step.text) !== null,
302
+ generator: (step, context) => {
303
+ const state = qualifiedState(step.text);
304
+ let resolved = {};
305
+ try {
306
+ resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
307
+ }
308
+ catch (error) { }
309
+ let dataValue;
310
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
311
+ // "selected" mirrors selected-assertion: checkbox/radio/switch roles have no
312
+ // aria-selected — their state is `checked`; other targets probe at runtime.
313
+ let stateAssertion = state.assertion;
314
+ let selectedProbe = state.probe === true;
315
+ // A checked/unchecked assertion must NOT compile to toBeChecked() — it throws
316
+ // "Not a checkbox or radio button" on an aria-pressed toggle exactly as the action side did
317
+ // (#675). Flag it so the template can route it through expectCheckedState instead, which
318
+ // reads the same signals setCheckedState drives.
319
+ let isCheckedAssertion = state.label === 'checked' || state.label === 'unchecked';
320
+ let checkedWant = state.label === 'checked';
321
+ if (selectedProbe && resolved.role && ['checkbox', 'radio', 'switch'].includes(resolved.role)) {
322
+ stateAssertion = 'toBeChecked()';
323
+ selectedProbe = false;
324
+ isCheckedAssertion = true;
325
+ checkedWant = true;
326
+ }
327
+ const code = context.templateEngine.renderStep('state-with-filter-assertion', {
328
+ ...resolved,
329
+ selectorRef: step.selectorRef,
330
+ dataValue,
331
+ stateAssertion,
332
+ selectedProbe,
333
+ isCheckedAssertion,
334
+ checkedWant,
335
+ });
336
+ return { code, comment: `Assert ${step.selectorRef} with ${step.dataRef} is ${state.label}` };
337
+ },
338
+ priority: 14,
339
+ },
340
+ // NEW: Assertion with variable (handles both empty selector and static + variable)
341
+ // Pattern: Then User see [error] message with {{fail_message}}
342
+ // If selector YAML has empty value, uses variable-only matching
343
+ // If selector has value, combines both (static text + variable)
344
+ // Input types → toHaveValue, everything else → toHaveText
345
+ {
346
+ name: 'see-with-variable',
347
+ matcher: (step) => (step.text.includes('should see') ||
348
+ step.text.match(/\b(see|sees)\s+\[/)) &&
349
+ !!step.selectorRef &&
350
+ !!step.dataRef &&
351
+ step.text.includes('with'),
352
+ generator: (step, context) => {
353
+ // Input element types use toHaveValue instead of toHaveText
354
+ const INPUT_TYPES = new Set([
355
+ 'field', 'textarea', 'search', 'dropdown', 'slider', 'date-picker',
356
+ 'input', 'textbox', 'editor', 'select', 'combobox',
357
+ ]);
358
+ // Subset of INPUT_TYPES whose DOM implementation cannot be inferred from the token:
359
+ // a component library may render any of these as a non-input trigger element.
360
+ const AMBIGUOUS_VALUE_TYPES = new Set(['dropdown', 'select', 'combobox']);
361
+ // Resolve data variable value
362
+ let dataValue;
363
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
364
+ // Resolve selector from YAML with feature context
365
+ let resolved = {};
366
+ try {
367
+ resolved = context.selectorResolver.resolveSelector(step.selectorRef, context.featureName, step.elementType, step.nth);
368
+ }
369
+ catch (error) {
370
+ // Selector not in YAML or context issue - will use variable-only
371
+ }
372
+ // --- Attribute assertion: toHaveAttribute ---
373
+ // When selector YAML has `attribute` field (e.g., attribute: 'src', 'href')
374
+ if (resolved.attribute) {
375
+ const code = context.templateEngine.renderStep('attribute-assertion', {
376
+ ...resolved,
377
+ dataValue,
378
+ });
379
+ return {
380
+ code,
381
+ comment: `Assert ${step.selectorRef} ${resolved.attribute} matches ${step.dataRef}`,
382
+ };
383
+ }
384
+ // --- Input types: toHaveValue ---
385
+ // `dropdown`/`select`/`combobox` are AMBIGUOUS: ARIA role `combobox` is worn both by a
386
+ // native <select> and by a `<button role="combobox">` trigger (Radix/shadcn, MUI,
387
+ // Headless UI). toHaveValue() reads the DOM `value` property, which a button has not got
388
+ // — hence the runtime "Not an input element". Resolution follows the app-profile layering
389
+ // (sungen_app_profile_spec.md §3): per-element `variant:` / app-profile recipe wins, and
390
+ // the built-in default probes the tag at runtime instead of assuming a native input.
391
+ if (step.elementType && INPUT_TYPES.has(step.elementType)) {
392
+ const ambiguous = (AMBIGUOUS_VALUE_TYPES.has(step.elementType) ||
393
+ ['combobox', 'listbox'].includes(resolved.role)) &&
394
+ resolved.variant !== 'native';
395
+ if (ambiguous) {
396
+ // `assertValue` is the third recipe intent the app-profile spec defines alongside
397
+ // `fill`/`select` (spec §3.1). Same lookup shape as the select-dropdown fork.
398
+ const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
399
+ const recipeSteps = variantName && variantName !== 'native'
400
+ ? context.appProfile?.recipes?.[variantName]?.['assertValue']
401
+ : undefined;
402
+ if (recipeSteps) {
403
+ try {
404
+ const steps = (0, sungen_2.expandRecipe)(recipeSteps, {
405
+ triggerCode: context.renderLocator(resolved),
406
+ value: dataValue,
407
+ });
408
+ return {
409
+ code: context.templateEngine.renderStep('recipe-sequence', { steps }),
410
+ comment: `Assert ${step.selectorRef} has value ${step.dataRef} (${variantName} recipe)`,
411
+ };
412
+ }
413
+ catch {
414
+ // malformed recipe -> fall through to the runtime probe below
415
+ }
416
+ }
417
+ }
418
+ const code = context.templateEngine.renderStep('have-value-assertion', {
419
+ ...resolved,
420
+ dataValue,
421
+ probeNative: ambiguous,
422
+ });
423
+ return {
424
+ code,
425
+ comment: `Assert ${step.selectorRef} has value ${step.dataRef}`,
426
+ };
427
+ }
428
+ // --- Label-value pattern: "User see [X] label with {{Y}}" ---
429
+ if (step.elementType === 'label' && step.dataRef) {
430
+ // A `label` ref reads two different ways, and the selectors.yaml entry decides which.
431
+ //
432
+ // NO ENTRY, or an entry that finds its element BY TEXT — the ref names on-screen
433
+ // CAPTION text rather than an element, so the assertion is "the screen shows
434
+ // `X <value>`". That is what label-value-assertion renders, and it stays the default.
435
+ //
436
+ // AN ENTRY NAMING A CONCRETE NODE (xpath / accessibility-id / testid / id / css / …) —
437
+ // the author has said WHERE the value lives. That locator used to be resolved and then
438
+ // thrown away, leaving a page-wide text match: on Android `UiSelector().text(v)`, which
439
+ // reads only @text, so a Flutter/RN node exposing @content-desc never matched. The
440
+ // entry was correct and the step failed every time (#666). Honouring it puts `label`
441
+ // alongside `header`, which never had the special case.
442
+ //
443
+ // CONTAINS, not exact: an entry for a label/value pair points legitimately either at
444
+ // the value node ("JD-001") or at the row holding both ("Job No: JD-001"), and the web
445
+ // regex it replaces was itself anchored only at the end. `exact: true` opts into the
446
+ // strict comparison.
447
+ //
448
+ // `resolved` is the SAME lookup, already performed above — this branch used to repeat it.
449
+ if (IDENTITY_STRATEGIES.has(resolved.strategy)) {
450
+ const code = context.templateEngine.renderStep(resolved.exact ? 'have-text-assertion' : 'contain-text-assertion', { ...resolved, expectedText: dataValue });
451
+ return {
452
+ code,
453
+ comment: `Assert ${step.selectorRef} label ${resolved.exact ? 'has' : 'contains'} value ${step.dataRef}`,
454
+ };
455
+ }
456
+ // `value: ''` is the declared opt-out: drop the caption prefix, match the value alone.
457
+ const label = resolved.value !== undefined && resolved.value.trim() === ''
458
+ ? undefined
459
+ : step.selectorRef;
460
+ // The locator here is built from the CAPTION, not from `resolved` — the common case has no
461
+ // selectors.yaml entry at all, so `resolved` is `{}` and carries nothing. The scope
462
+ // therefore comes off the context, which `withParentScope` sets whether or not the entry
463
+ // lookup succeeded; without it `… in [Education Card 6] card` was dropped silently and the
464
+ // assertion read whichever card the page happened to render first (#747).
465
+ const code = context.templateEngine.renderStep('label-value-assertion', {
466
+ label,
467
+ dataValue,
468
+ parentLocator: context.parentLocator,
469
+ });
470
+ return {
471
+ code,
472
+ comment: `Assert ${step.selectorRef} label with value ${step.dataRef}`,
473
+ };
474
+ }
475
+ // --- Dialog role: check heading inside dialog ---
476
+ if (resolved.strategy === 'role' && resolved.role === 'dialog') {
477
+ const code = context.templateEngine.renderStep('visible-dialog-heading-assertion', {
478
+ dataValue,
479
+ });
480
+ return {
481
+ code,
482
+ comment: `Assert ${step.selectorRef} dialog with heading ${step.dataRef}`,
483
+ };
484
+ }
485
+ // --- Image role: images have no text, use name ---
486
+ if (resolved.strategy === 'role' && resolved.role === 'img') {
487
+ const hasName = resolved.name && resolved.name.trim();
488
+ const code = context.templateEngine.renderStep('visible-with-role-variable-assertion', {
489
+ role: resolved.role,
490
+ name: hasName ? resolved.name : dataValue,
491
+ exact: resolved.exact || false,
492
+ nth: resolved.nth,
493
+ // Rebuilt field by field rather than spread from `resolved`, so the scope `withParentScope`
494
+ // stamped there never arrives — the label branch's twin defect (#747).
495
+ parentLocator: resolved.parentLocator ?? context.parentLocator,
496
+ });
497
+ return {
498
+ code,
499
+ comment: `Assert ${step.selectorRef} image with name ${step.dataRef}`,
500
+ };
501
+ }
502
+ // --- Everything else: toHaveText (exact full match) ---
503
+ // Locator-strategy LIST MEMBERS first: `see [X] item|option|cell with {{v}}` on a
504
+ // CSS-locator list means "a member whose text is v exists". A bare toHaveText on the
505
+ // multi-matching locator strict-mode-fails, so filter by the value then assert
506
+ // visible — the locator counterpart of the role-based branch below. The filter
507
+ // matches by SUBSTRING (Playwright's standard hasText) unless the selector YAML
508
+ // declares `exact: true` — the same contract as the role branch; a hardcoded exact
509
+ // here rejected members whose rendered text wraps the value (icons, counts, i18n
510
+ // suffixes) with no way to opt out. Unique-element types (text/message/…)
511
+ // keep toHaveText: that is a VALUE assertion and must not be weakened to existence.
512
+ if (resolved.strategy === 'locator' && step.elementType && LOCATOR_LIST_MEMBER_TYPES.has(step.elementType)) {
513
+ const code = context.templateEngine.renderStep('visible-filtered-assertion', {
514
+ ...resolved,
515
+ dataValue,
516
+ });
517
+ return {
518
+ code,
519
+ comment: `Assert ${step.selectorRef} with text ${step.dataRef} is present`,
520
+ };
521
+ }
522
+ // Locator strategy
523
+ if (resolved.strategy === 'locator') {
524
+ const code = context.templateEngine.renderStep('have-text-assertion', {
525
+ ...resolved,
526
+ expectedText: dataValue,
527
+ });
528
+ return {
529
+ code,
530
+ comment: `Assert ${step.selectorRef} has text ${step.dataRef}`,
531
+ };
532
+ }
533
+ // List-member roles: `see [X] item with {{v}}` on a dynamic list/dropdown means
534
+ // "an item whose text is v exists" — filter the (many-matching) locator by the value
535
+ // then assert visible, mirroring click-element-with-text. Plain toHaveText on the bare
536
+ // getByRole('listitem') strict-mode-fails when many match. SCOPED to role-based
537
+ // list-member roles only (listitem/option/cell) — unique labelled elements keep
538
+ // toHaveText (value check), and locator-type lists are unchanged. `row` is excluded
539
+ // (table rows have dedicated existence patterns).
540
+ // The name is NOT stripped here any more. It used to be dropped unconditionally, which also
541
+ // discarded a `name:` the user had declared explicitly — and the strip only ever protected
542
+ // THIS branch, leaving `is hidden` / `is disabled` to compile the invented name into a locator
543
+ // that matches nothing. The resolver now withholds the invented name at its source
544
+ // (LIST_MEMBER_ROLES in selector-resolver), so every branch is covered and an explicit name
545
+ // still reaches the locator. What stays here is the ROUTING: filter-then-visible instead of
546
+ // toHaveText, because a bare getByRole('listitem') strict-mode-fails when many match.
547
+ if (resolved.strategy === 'role' && resolved.role && sungen_3.LIST_MEMBER_ROLES.has(resolved.role)) {
548
+ const code = context.templateEngine.renderStep('visible-filtered-assertion', {
549
+ ...resolved,
550
+ dataValue,
551
+ });
552
+ return {
553
+ code,
554
+ comment: `Assert ${step.selectorRef} with text ${step.dataRef} is present`,
555
+ };
556
+ }
557
+ // Role-based selector
558
+ if (resolved.strategy === 'role' && resolved.role) {
559
+ const hasName = resolved.name && resolved.name.trim();
560
+ const code = context.templateEngine.renderStep('have-text-assertion', {
561
+ ...resolved,
562
+ expectedText: dataValue,
563
+ });
564
+ return {
565
+ code,
566
+ comment: `Assert ${step.selectorRef} has text ${step.dataRef}`,
567
+ };
568
+ }
569
+ // Text/placeholder/testid/other strategies
570
+ const code = context.templateEngine.renderStep('have-text-assertion', {
571
+ ...resolved,
572
+ expectedText: dataValue,
573
+ });
574
+ return {
575
+ code,
576
+ comment: `Assert ${step.selectorRef} has text ${step.dataRef}`,
577
+ };
578
+ },
579
+ priority: 13,
580
+ },
581
+ {
582
+ name: 'should-be-visible',
583
+ // `is visible` is redundant (bare `see [X] type` already asserts it) but harmless — the review
584
+ // checklist flags the redundancy; the compiler must not turn it into a hard failure.
585
+ states: ['visible'],
586
+ matcher: (step) => (step.text.includes('should be visible') ||
587
+ step.text.includes('should be displayed') ||
588
+ step.text.includes('should see') ||
589
+ step.text.match(/\b(see|sees)\s+\[/)) &&
590
+ !!step.selectorRef,
591
+ resolver: (step, context) => {
592
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
593
+ return {
594
+ templateName: 'visible-assertion',
595
+ data: { ...resolved, selectorRef: step.selectorRef },
596
+ comment: `Assert ${step.selectorRef} is visible`,
597
+ };
598
+ },
599
+ priority: 10,
600
+ },
601
+ {
602
+ name: 'is-hidden',
603
+ states: ['hidden'],
604
+ // "see [target] is hidden" — no variable version; with-variable handled by see-with-variable-hidden
605
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis hidden\b/.test(step.text) &&
606
+ !step.dataRef && !!step.selectorRef,
607
+ resolver: (step, context) => {
608
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
609
+ return {
610
+ templateName: 'is-hidden-assertion',
611
+ data: { ...resolved, selectorRef: step.selectorRef },
612
+ comment: `Assert ${step.selectorRef} is not visible`,
613
+ };
614
+ },
615
+ priority: 11,
616
+ },
617
+ {
618
+ name: 'should-be-enabled',
619
+ states: ['enabled'],
620
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis enabled\b/.test(step.text) && !!step.selectorRef,
621
+ resolver: (step, context) => {
622
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
623
+ return {
624
+ templateName: 'enabled-assertion',
625
+ data: { ...resolved, selectorRef: step.selectorRef },
626
+ comment: `Assert ${step.selectorRef} is enabled`,
627
+ };
628
+ },
629
+ priority: 11,
630
+ },
631
+ {
632
+ name: 'should-be-disabled',
633
+ states: ['disabled'],
634
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis disabled\b/.test(step.text) && !!step.selectorRef,
635
+ resolver: (step, context) => {
636
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
637
+ return {
638
+ templateName: 'disabled-assertion',
639
+ data: { ...resolved, selectorRef: step.selectorRef },
640
+ comment: `Assert ${step.selectorRef} is disabled`,
641
+ };
642
+ },
643
+ priority: 11,
644
+ },
645
+ {
646
+ // `matches "<format>"` — assert the SHAPE of a label, not its value. A spec states a label's
647
+ // contract as "Format: yyyy/mm/dd ~ yyyy/mm/dd" or "Format: 残りX個"; neither existing text
648
+ // assertion can carry that (an exact value pins data that changes daily, `contains` passes on
649
+ // anything). Requires the quoted form so it cannot claim the API-side `matches schema [Ref]`.
650
+ // Priority above should-have-text / should-contain-text (both 12): those match on the words
651
+ // "has text" / "contains", which a format step does not use, but the generic visibility
652
+ // catch-all would otherwise take an unrecognised `see [X] label matches …`.
653
+ name: 'matches-format',
654
+ matcher: (step) => /\bmatches\s+"/i.test(step.text) && !!step.selectorRef && !!step.value,
655
+ resolver: (step, context) => {
656
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
657
+ return {
658
+ templateName: 'matches-format-assertion',
659
+ // `format` rides along so a failure message can name the shape the author asked for —
660
+ // a bare regex in the error is the hardest kind of red for a QA to read.
661
+ data: { ...resolved, selectorRef: step.selectorRef, formatRegex: (0, sungen_1.formatToRegexLiteral)(step.value), format: step.value },
662
+ comment: `Assert ${step.selectorRef} matches the format "${step.value}"`,
663
+ };
664
+ },
665
+ priority: 16,
666
+ },
667
+ {
668
+ name: 'should-contain-text',
669
+ matcher: (step) => (step.text.includes('should contain') || step.text.includes('contains')) && !!step.selectorRef && !!(step.value || step.dataRef),
670
+ resolver: (step, context) => {
671
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
672
+ const expectedText = step.value || context.dataResolver.resolveData(step.dataRef, context.featureName);
673
+ const comment = `Assert ${step.selectorRef} contains "${step.value || step.dataRef}"`;
674
+ // LIST MEMBERS (locator item/option/cell types, role listitem/option/cell): the
675
+ // locator matches many elements by design, so a bare toContainText strict-mode-fails.
676
+ // `contains` on a list means "a member containing v exists" — filter by the substring
677
+ // then assert visible, the same routing the with-{{v}} existence branches use.
678
+ // Always substring here (never the YAML `exact`): the step itself says "contains".
679
+ // Unique elements keep toContainText below — a VALUE assertion is not weakened.
680
+ // A role entry pinned to ONE element (explicit `name:` or `nth:`) counts as unique:
681
+ // filter-then-visible would turn its value check into a visibility gate (a collapsed
682
+ // but present member would flip from pass to fail with a worse failure message).
683
+ const isListMember = (resolved.strategy === 'locator' && step.elementType && LOCATOR_LIST_MEMBER_TYPES.has(step.elementType)) ||
684
+ (resolved.strategy === 'role' && !!resolved.role && sungen_3.LIST_MEMBER_ROLES.has(resolved.role) &&
685
+ !(resolved.name && resolved.name.trim()) && resolved.nth == null);
686
+ if (isListMember) {
687
+ return {
688
+ templateName: 'visible-filtered-assertion',
689
+ data: { ...resolved, exact: false, dataValue: expectedText },
690
+ comment,
691
+ };
692
+ }
693
+ return {
694
+ templateName: 'contain-text-assertion',
695
+ data: { ...resolved, expectedText },
696
+ comment,
697
+ };
698
+ },
699
+ priority: 12,
700
+ },
701
+ {
702
+ name: 'should-have-text',
703
+ matcher: (step) => (step.text.includes('should have text') || step.text.includes('has text')) && !!step.selectorRef && !!(step.value || step.dataRef),
704
+ resolver: (step, context) => {
705
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
706
+ const expectedText = step.value || context.dataResolver.resolveData(step.dataRef, context.featureName);
707
+ return {
708
+ templateName: 'have-text-assertion',
709
+ data: { ...resolved, expectedText },
710
+ comment: `Assert ${step.selectorRef} has text "${step.value || step.dataRef}"`,
711
+ };
712
+ },
713
+ priority: 12,
714
+ },
715
+ {
716
+ name: 'is-empty',
717
+ states: ['empty'],
718
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis empty\b/.test(step.text) && !!step.selectorRef,
719
+ resolver: (step, context) => {
720
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
721
+ return {
722
+ templateName: 'empty-assertion',
723
+ data: { ...resolved, selectorRef: step.selectorRef },
724
+ comment: `Assert ${step.selectorRef} is empty`,
725
+ };
726
+ },
727
+ priority: 11,
728
+ },
729
+ {
730
+ name: 'is-checked',
731
+ states: ['checked'],
732
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis checked\b/.test(step.text) && !!step.selectorRef,
733
+ resolver: (step, context) => {
734
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
735
+ return {
736
+ templateName: 'checked-assertion',
737
+ data: { ...resolved, selectorRef: step.selectorRef },
738
+ comment: `Assert ${step.selectorRef} is checked`,
739
+ };
740
+ },
741
+ priority: 11,
742
+ },
743
+ {
744
+ name: 'is-unchecked',
745
+ states: ['unchecked'],
746
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis unchecked\b/.test(step.text) && !!step.selectorRef,
747
+ resolver: (step, context) => {
748
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
749
+ return {
750
+ templateName: 'not-checked-assertion',
751
+ data: { ...resolved, selectorRef: step.selectorRef },
752
+ comment: `Assert ${step.selectorRef} is unchecked`,
753
+ };
754
+ },
755
+ priority: 11,
756
+ },
757
+ {
758
+ name: 'is-focused',
759
+ states: ['focused'],
760
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis focused\b/.test(step.text) && !!step.selectorRef,
761
+ resolver: (step, context) => {
762
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
763
+ return {
764
+ templateName: 'focused-assertion',
765
+ data: { ...resolved, selectorRef: step.selectorRef },
766
+ comment: `Assert ${step.selectorRef} is focused`,
767
+ };
768
+ },
769
+ priority: 11,
770
+ },
771
+ {
772
+ name: 'see-data-text',
773
+ matcher: (step) => (step.text.includes('should see') ||
774
+ step.text.match(/\b(see|sees)\b/)) &&
775
+ !step.selectorRef &&
776
+ !!step.dataRef,
777
+ resolver: (step, context) => {
778
+ // Resolve data reference to actual value
779
+ let dataValue;
780
+ dataValue = context.dataResolver.resolveData(step.dataRef, context.featureName);
781
+ return {
782
+ templateName: 'visible-assertion',
783
+ data: {
784
+ strategy: 'text',
785
+ value: dataValue,
786
+ },
787
+ comment: `Assert ${step.dataRef} is visible`,
788
+ };
789
+ },
790
+ priority: 5, // Lower than selector-based assertions
791
+ },
792
+ {
793
+ name: 'list-item-count',
794
+ matcher: (step) => step.text.includes('should have') && step.text.includes('count') &&
795
+ !!step.selectorRef && step.text.includes('items') &&
796
+ (step.elementType === 'list' || step.elementType === 'list-item' || step.elementType === 'listitem'),
797
+ resolver: (step, context) => {
798
+ let expectedCount = 1;
799
+ const match = step.text.match(/count\s+(\d+)/);
800
+ if (match) {
801
+ expectedCount = parseInt(match[1]);
802
+ }
803
+ else if (step.dataRef) {
804
+ const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
805
+ const parsed = parseInt(resolvedData);
806
+ expectedCount = isNaN(parsed) ? 1 : parsed;
807
+ }
808
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
809
+ return {
810
+ templateName: 'list-item-count-assertion',
811
+ data: { ...resolved, expectedCount },
812
+ comment: `Assert ${step.selectorRef} list has ${expectedCount} items`,
813
+ };
814
+ },
815
+ priority: 9, // Higher than should-have-count (8)
816
+ },
817
+ {
818
+ name: 'should-have-count',
819
+ matcher: (step) => step.text.includes('should have') && step.text.includes('count') && !!step.selectorRef,
820
+ resolver: (step, context) => {
821
+ let expectedCount = 1;
822
+ // Try literal digit first: "count 10"
823
+ const match = step.text.match(/count\s+(\d+)/);
824
+ if (match) {
825
+ expectedCount = parseInt(match[1]);
826
+ }
827
+ else if (step.dataRef) {
828
+ // Resolve data reference: "count {{number_items}}"
829
+ const resolvedData = context.dataResolver.resolveData(step.dataRef, context.featureName);
830
+ const parsed = parseInt(resolvedData);
831
+ expectedCount = isNaN(parsed) ? 1 : parsed;
832
+ }
833
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
834
+ return {
835
+ templateName: 'count-assertion',
836
+ data: { ...resolved, expectedCount },
837
+ comment: `Assert ${step.selectorRef} has count ${expectedCount}`,
838
+ };
839
+ },
840
+ priority: 8,
841
+ },
842
+ {
843
+ name: 'is-loading',
844
+ states: ['loading'],
845
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis loading\b/.test(step.text) && !!step.selectorRef,
846
+ resolver: (step, context) => {
847
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
848
+ return {
849
+ templateName: 'loading-assertion',
850
+ data: { ...resolved, selectorRef: step.selectorRef },
851
+ comment: `Assert ${step.selectorRef} is loading`,
852
+ };
853
+ },
854
+ priority: 11,
855
+ },
856
+ {
857
+ name: 'is-selected',
858
+ states: ['selected'],
859
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bis selected\b/.test(step.text) && !!step.selectorRef,
860
+ resolver: (step, context) => {
861
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
862
+ return {
863
+ templateName: 'selected-assertion',
864
+ data: { ...resolved, selectorRef: step.selectorRef },
865
+ comment: `Assert ${step.selectorRef} is selected`,
866
+ };
867
+ },
868
+ priority: 11,
869
+ },
870
+ {
871
+ name: 'is-sorted-ascending',
872
+ platforms: ['web'],
873
+ platformAlternative: 'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
874
+ states: ['sorted ascending'],
875
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bsorted ascending\b/.test(step.text) && !!step.selectorRef,
876
+ resolver: (step, context) => {
877
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
878
+ return {
879
+ templateName: 'sorted-assertion',
880
+ data: { ...resolved, selectorRef: step.selectorRef, sortDirection: 'ascending' },
881
+ comment: `Assert ${step.selectorRef} is sorted ascending`,
882
+ };
883
+ },
884
+ priority: 12,
885
+ },
886
+ {
887
+ name: 'is-sorted-descending',
888
+ platforms: ['web'],
889
+ platformAlternative: 'aria-sort has no native equivalent - read the item texts with per-element assertions and compare them, or tag the scenario @manual',
890
+ states: ['sorted descending'],
891
+ matcher: (step) => /\b(see|sees)\s+\[/.test(step.text) && /\bsorted descending\b/.test(step.text) && !!step.selectorRef,
892
+ resolver: (step, context) => {
893
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef, undefined, step.elementType, step.nth);
894
+ return {
895
+ templateName: 'sorted-assertion',
896
+ data: { ...resolved, selectorRef: step.selectorRef, sortDirection: 'descending' },
897
+ comment: `Assert ${step.selectorRef} is sorted descending`,
898
+ };
899
+ },
900
+ priority: 12,
901
+ },
902
+ ];
903
+ //# sourceMappingURL=assertion-patterns.js.map