@sungen/driver-ui 3.2.13 → 3.2.15

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.13",
3
+ "version": "3.2.15",
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.13"
16
+ "@sun-asterisk/sungen": "3.2.15"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -6,6 +6,22 @@ import { pathToRegexSource } from '../utils/url-path-regex';
6
6
  * Assertion patterns: visibility, text content, state, attributes
7
7
  * Uses template engine for framework-agnostic code generation
8
8
  */
9
+
10
+ /** State qualifiers that may follow `with {{value}}` and need value-filtered handling
11
+ * (see-with-variable-state). `is disabled`/`is hidden` are EXCLUDED — they keep their
12
+ * dedicated -disabled/-hidden twins. `is unchecked` is tested before `is checked` so
13
+ * the substring never misclassifies. `selected` probes at runtime (aria-selected vs
14
+ * checked) exactly like selected-assertion. */
15
+ function qualifiedState(text: string): { assertion: string; label: string; probe?: boolean } | null {
16
+ if (/\bis enabled\b/.test(text)) return { assertion: 'toBeEnabled()', label: 'enabled' };
17
+ if (/\bis unchecked\b/.test(text)) return { assertion: 'toBeChecked({ checked: false })', label: 'unchecked' };
18
+ if (/\bis checked\b/.test(text)) return { assertion: 'toBeChecked()', label: 'checked' };
19
+ if (/\bis focused\b/.test(text)) return { assertion: 'toBeFocused()', label: 'focused' };
20
+ if (/\bis empty\b/.test(text)) return { assertion: 'toBeEmpty()', label: 'empty' };
21
+ if (/\bis selected\b/.test(text)) return { assertion: '', label: 'selected', probe: true };
22
+ return null;
23
+ }
24
+
9
25
  export const assertionPatterns: StepPattern[] = [
10
26
  // Browser alert text assertion: "see [Are you sure?] alert"
11
27
  {
@@ -245,6 +261,60 @@ export const assertionPatterns: StepPattern[] = [
245
261
  return { code, comment: `Assert ${step.selectorRef} disabled with ${step.dataRef}` };
246
262
  },
247
263
  priority: 14,
264
+ },
265
+ // Pattern: Then User see [Time Slot] option with {{time}} is enabled|selected|checked|…
266
+ // The value-qualified STATE family. Mirrors see-with-variable-hidden/-disabled (both p14):
267
+ // without this twin the generic see-with-variable (p13) swallowed the step and emitted a
268
+ // bare toHaveText — the state qualifier was silently DROPPED, and on a multi-match
269
+ // `type: locator` selector (e.g. a 96-slot time picker) the unfiltered locator also
270
+ // strict-mode-failed. Filter by the EXACT value first, then assert the state.
271
+ // `is disabled` / `is hidden` keep their existing dedicated twins — not matched here.
272
+ {
273
+ name: 'see-with-variable-state',
274
+ matcher: (step: ParsedStep) =>
275
+ (step.text.includes('should see') || !!step.text.match(/\b(see|sees)\s+\[/)) &&
276
+ !!step.selectorRef &&
277
+ !!step.dataRef &&
278
+ step.text.includes('with') &&
279
+ qualifiedState(step.text) !== null,
280
+ generator: (step, context) => {
281
+ const state = qualifiedState(step.text)!;
282
+
283
+ let resolved: any = {};
284
+ try {
285
+ resolved = context.selectorResolver.resolveSelector(
286
+ step.selectorRef!,
287
+ context.featureName,
288
+ step.elementType,
289
+ step.nth
290
+ );
291
+ } catch (error) {}
292
+
293
+ let dataValue: string;
294
+ try {
295
+ dataValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
296
+ } catch (error) {
297
+ dataValue = `\${${step.dataRef}}`;
298
+ }
299
+
300
+ // "selected" mirrors selected-assertion: checkbox/radio/switch roles have no
301
+ // aria-selected — their state is `checked`; other targets probe at runtime.
302
+ let stateAssertion = state.assertion;
303
+ let selectedProbe = state.probe === true;
304
+ if (selectedProbe && resolved.role && ['checkbox', 'radio', 'switch'].includes(resolved.role)) {
305
+ stateAssertion = 'toBeChecked()';
306
+ selectedProbe = false;
307
+ }
308
+
309
+ const code = context.templateEngine.renderStep('state-with-filter-assertion', {
310
+ ...resolved,
311
+ dataValue,
312
+ stateAssertion,
313
+ selectedProbe,
314
+ });
315
+ return { code, comment: `Assert ${step.selectorRef} with ${step.dataRef} is ${state.label}` };
316
+ },
317
+ priority: 14,
248
318
  },
249
319
  // NEW: Assertion with variable (handles both empty selector and static + variable)
250
320
  // Pattern: Then User see [error] message with {{fail_message}}
@@ -366,6 +436,24 @@ export const assertionPatterns: StepPattern[] = [
366
436
  }
367
437
 
368
438
  // --- Everything else: toHaveText (exact full match) ---
439
+ // Locator-strategy LIST MEMBERS first: `see [X] item|option|cell with {{v}}` on a
440
+ // CSS-locator list (custom widgets with no role — e.g. a 96-slot time picker) means
441
+ // "a member whose text is v exists". A bare toHaveText on the multi-matching locator
442
+ // strict-mode-fails, so filter by the EXACT value then assert visible — the locator
443
+ // counterpart of the role-based branch below. Unique-element types (text/message/…)
444
+ // keep toHaveText: that is a VALUE assertion and must not be weakened to existence.
445
+ const LOCATOR_LIST_MEMBER_TYPES = new Set(['item', 'listitem', 'option', 'cell']);
446
+ if (resolved.strategy === 'locator' && step.elementType && LOCATOR_LIST_MEMBER_TYPES.has(step.elementType)) {
447
+ const code = context.templateEngine.renderStep('visible-filtered-assertion', {
448
+ ...resolved,
449
+ dataValue,
450
+ exact: true,
451
+ });
452
+ return {
453
+ code,
454
+ comment: `Assert ${step.selectorRef} with text ${step.dataRef} is present`,
455
+ };
456
+ }
369
457
  // Locator strategy
370
458
  if (resolved.strategy === 'locator') {
371
459
  const code = context.templateEngine.renderStep('have-text-assertion', {
@@ -136,11 +136,35 @@ export const tablePatterns: StepPattern[] = [
136
136
  );
137
137
  const filterValue = context.dataResolver.resolveData(step.dataRef!, context.featureName);
138
138
 
139
+ // Field report: the row label used to be DECORATIVE (comment-only) — a
140
+ // positional intent like `[First Contact Row]` silently degraded to an
141
+ // anywhere-in-the-table existence check, so ordering rules never failed.
142
+ // Now the label follows the same "resolve via selectors.yaml, fall back
143
+ // when absent" convention as every other [Ref]: a `type: locator` entry
144
+ // (RELATIVE to the table, e.g. `tbody tr:first-child`) positions the row,
145
+ // and the assertion becomes toContainText on that exact row. No entry →
146
+ // the historical filter-by-value behavior, byte-identical.
147
+ let rowSelector: string | undefined;
148
+ if (rowLabel && rowLabel !== tableName) {
149
+ try {
150
+ const rowResolved = context.selectorResolver.resolveSelector(
151
+ rowLabel, context.featureName, 'row', 0
152
+ );
153
+ if (rowResolved.strategy === 'locator' && rowResolved.value && rowResolved.value.trim()) {
154
+ rowSelector = rowResolved.value;
155
+ }
156
+ } catch {
157
+ // no selectors.yaml entry for the row label — display-only label (fallback)
158
+ }
159
+ }
160
+
139
161
  const isGiven = isGivenContext(context);
140
162
  return {
141
163
  templateName: 'table-row-exists',
142
- data: { ...resolved, filterValue, isGiven },
143
- comment: `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table with ${step.dataRef}`,
164
+ data: { ...resolved, filterValue, isGiven, rowSelector },
165
+ comment: rowSelector
166
+ ? `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} (positioned row) in ${tableName} table contains ${step.dataRef}`
167
+ : `${isGiven ? 'Wait' : 'Assert'} ${rowLabel} row in ${tableName} table with ${step.dataRef}`,
144
168
  };
145
169
  },
146
170
  priority: 17,
@@ -310,27 +334,55 @@ export const tablePatterns: StepPattern[] = [
310
334
 
311
335
  const dataTable = step.dataTable!;
312
336
 
313
- // Resolve {{variable}} references in cell values
314
- const resolvedRows = dataTable.rows.map(row => ({
315
- cells: row.cells.map(cell => {
316
- const varMatch = cell.match(/^\{\{([a-z0-9\-\.\_]+)\}\}$/i);
317
- if (varMatch) {
318
- return context.dataResolver.resolveData(varMatch[1], context.featureName);
319
- }
320
- return cell;
321
- }),
322
- }));
323
-
324
- // Pre-compute filter-based assertion lines
325
- // For each expected row: chain .filter({ hasText }) for all cell values, then assert visible
337
+ // Resolve {{variable}} references in a cell (headers included — i18n headers are legal)
338
+ const resolveCell = (cell: string): string => {
339
+ const varMatch = cell.match(/^\{\{([a-z0-9\-\.\_]+)\}\}$/i);
340
+ if (varMatch) {
341
+ return context.dataResolver.resolveData(varMatch[1], context.featureName);
342
+ }
343
+ return cell;
344
+ };
345
+ const headers = dataTable.headers.map(resolveCell);
346
+ const resolvedRows = dataTable.rows.map(row => ({ cells: row.cells.map(resolveCell) }));
347
+ const esc = (s: string) => s.replace(/'/g, "\\'");
348
+
349
+ // Header → cell mapping (field report: the header row was parsed and then IGNORED —
350
+ // rows were asserted as chained row-level hasText filters, so column-swapped data and
351
+ // duplicate values like '-' still passed). Now:
352
+ // 1. span guard — rowspan/colspan shifts ordinal mapping; fail loud instead of
353
+ // asserting the wrong cells (span support is a documented follow-up);
354
+ // 2. resolve each DataTable header to its DOM column index AT RUNTIME by header text
355
+ // (exact after trim, else a UNIQUE contains-match to tolerate sort icons); a
356
+ // missing/ambiguous header fails with the actual header row in the message —
357
+ // robust to extra DOM columns (leading checkbox…) that break compile-time nth;
358
+ // 3. locate each expected row by the chained co-occurrence filters (kept — resilient
359
+ // to row order and extra rows), then prove EVERY cell under its own header with
360
+ // an exact toHaveText. `td, th` ordinals count all DOM cells on both the header
361
+ // and body sides, so indexes stay aligned even with hidden columns.
326
362
  const assertions: string[] = [];
363
+ assertions.push(
364
+ `await expect(table.locator('[rowspan]:not([rowspan="1"]), [colspan]:not([colspan="1"])'), 'table match data: rowspan/colspan tables are not supported yet — header-to-cell mapping would be wrong').toHaveCount(0);`
365
+ );
366
+ assertions.push(
367
+ `const __headers = (await table.locator('thead tr').first().locator('th, td').allTextContents()).map((t) => t.trim());`
368
+ );
369
+ assertions.push(
370
+ `const __colIdx = (h: string): number => { const i = __headers.indexOf(h); if (i !== -1) return i; const c = __headers.filter((t) => t.includes(h)); if (c.length === 1) return __headers.indexOf(c[0]); throw new Error(\`table match data: header "\${h}" \${c.length ? 'is ambiguous' : 'not found'} in table header row [\${__headers.join(' | ')}]\`); };`
371
+ );
327
372
  for (const row of resolvedRows) {
328
373
  const filters = row.cells
329
- .map(cell => `.filter({ hasText: '${cell.replace(/'/g, "\\'")}' })`)
374
+ .map(cell => `.filter({ hasText: '${esc(cell)}' })`)
330
375
  .join('');
331
- assertions.push(
332
- `await expect(rows${filters}).toBeVisible();`
333
- );
376
+ assertions.push(`{`);
377
+ assertions.push(` const row = rows${filters};`);
378
+ row.cells.forEach((cell, i) => {
379
+ const header = headers[i];
380
+ if (header === undefined) return;
381
+ assertions.push(
382
+ ` await expect(row.locator('td, th').nth(__colIdx('${esc(header)}'))).toHaveText('${esc(cell)}');`
383
+ );
384
+ });
385
+ assertions.push(`}`);
334
386
  }
335
387
 
336
388
  const isGiven = isGivenContext(context);