@sungen/driver-ui 3.2.35 → 3.3.0-alpha.10

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 (92) 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 +50 -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 +146 -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 +9 -3
  86. package/src/index.ts +5 -0
  87. package/src/patterns/assertion-patterns.ts +10 -88
  88. package/src/patterns/form-patterns.ts +2 -47
  89. package/src/patterns/table-patterns.ts +3 -15
  90. package/src/trace-identity/aria-snapshot-head.ts +33 -0
  91. package/src/trace-identity/playwright-identity-resolver.ts +123 -0
  92. package/src/trace-identity/trace-run-record-reporter.ts +131 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * URL-path → RegExp-source conversion for page URL assertions (`toHaveURL`) and
3
+ * navigation waits (`waitForURL`).
4
+ *
5
+ * A selector's `value` may carry dynamic route placeholders (`:id`, `:receptionId`,
6
+ * …), e.g. `/cn22/view/so/ocr/results/:id`. A live URL populates those segments with
7
+ * real values (`…/results/rec-dummy-000017`), so a fully-escaped literal pattern would
8
+ * never match. Placeholder segments become a single-segment wildcard (`[^/]+`); every
9
+ * other segment is escaped so its regex metacharacters stay literal.
10
+ */
11
+ /** Escape a literal string for safe use inside a RegExp source. */
12
+ export declare function escapeRegex(text: string): string;
13
+ /**
14
+ * Convert a URL path into a RegExp source string, turning dynamic route
15
+ * placeholders (`:name`) into `[^/]+` wildcards while escaping literal segments.
16
+ * `/` is emitted as the escaped `\/` so the result drops straight into `/…/`. A query or
17
+ * fragment in the input is dropped — see the comment in the body.
18
+ */
19
+ export declare function pathToRegexSource(path: string): string;
20
+ /** A page value split into the two patterns the runtime matches SEPARATELY. */
21
+ export interface UrlPatternParts {
22
+ /** Pattern for `u.pathname`. Always present. */
23
+ pathRegex: string;
24
+ /** Pattern for `u.hostname` — set only when the page value named an absolute URL. */
25
+ hostRegex?: string;
26
+ }
27
+ /**
28
+ * Split a page value into the host and path patterns the runtime matches separately.
29
+ *
30
+ * `u.hostname` never appears in `u.pathname`, so one concatenated regex cannot carry both — it
31
+ * only ever "worked" while the match ran unanchored across the whole URL, which is the looseness
32
+ * that let a wait resolve on a transient URL (#677). A page value that names an absolute URL
33
+ * therefore yields two patterns; a relative one yields the path pattern alone, exactly as before.
34
+ *
35
+ * Every caller that builds a pathname pattern for a URL assertion or wait goes through here, so
36
+ * the host half cannot be honoured on one step form and silently dropped on another: before this,
37
+ * only `wait for [X] page` split the host, while both assertion forms matched the path against
38
+ * ANY host — `see [X] page` with `https://admin.example.com/jobs` passed on the public site.
39
+ *
40
+ * Dynamic `:id` segments still wildcard; the host stays a literal. A value that starts like an
41
+ * absolute URL but does not parse falls back to the path-only reading rather than throwing — the
42
+ * pattern is then wrong in a way the author can read, which beats losing the step.
43
+ */
44
+ export declare function urlToPatternParts(path: string): UrlPatternParts;
45
+ //# sourceMappingURL=url-path-regex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-path-regex.d.ts","sourceRoot":"","sources":["../../src/utils/url-path-regex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CActD;AAED,+EAA+E;AAC/E,MAAM,WAAW,eAAe;IAC9B,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAQ/D"}
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * URL-path → RegExp-source conversion for page URL assertions (`toHaveURL`) and
4
+ * navigation waits (`waitForURL`).
5
+ *
6
+ * A selector's `value` may carry dynamic route placeholders (`:id`, `:receptionId`,
7
+ * …), e.g. `/cn22/view/so/ocr/results/:id`. A live URL populates those segments with
8
+ * real values (`…/results/rec-dummy-000017`), so a fully-escaped literal pattern would
9
+ * never match. Placeholder segments become a single-segment wildcard (`[^/]+`); every
10
+ * other segment is escaped so its regex metacharacters stay literal.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.escapeRegex = escapeRegex;
14
+ exports.pathToRegexSource = pathToRegexSource;
15
+ exports.urlToPatternParts = urlToPatternParts;
16
+ const REGEX_SPECIALS = /[.*+?^${}()|[\]\\/]/g;
17
+ /** Escape a literal string for safe use inside a RegExp source. */
18
+ function escapeRegex(text) {
19
+ return text.replace(REGEX_SPECIALS, '\\$&');
20
+ }
21
+ /**
22
+ * Convert a URL path into a RegExp source string, turning dynamic route
23
+ * placeholders (`:name`) into `[^/]+` wildcards while escaping literal segments.
24
+ * `/` is emitted as the escaped `\/` so the result drops straight into `/…/`. A query or
25
+ * fragment in the input is dropped — see the comment in the body.
26
+ */
27
+ function pathToRegexSource(path) {
28
+ // Every caller compiles this into a pattern tested against `u.pathname`, which never carries a
29
+ // query or a fragment. A page selector whose `value` includes one ('/search?tab=1' — off the
30
+ // documented "the value carries the PATH only" contract, but written in the field) would
31
+ // otherwise escape the `?` into the pattern and produce a match no real URL can satisfy: an
32
+ // assertion that can never pass, a wait that can only time out. Dropped here, once, rather than
33
+ // in each of the four resolvers that build a pathname pattern.
34
+ return path
35
+ .replace(/[?#].*$/, '')
36
+ .split('/')
37
+ .map((segment) => /^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ? '[^/]+' : escapeRegex(segment))
38
+ .join('\\/');
39
+ }
40
+ /**
41
+ * Split a page value into the host and path patterns the runtime matches separately.
42
+ *
43
+ * `u.hostname` never appears in `u.pathname`, so one concatenated regex cannot carry both — it
44
+ * only ever "worked" while the match ran unanchored across the whole URL, which is the looseness
45
+ * that let a wait resolve on a transient URL (#677). A page value that names an absolute URL
46
+ * therefore yields two patterns; a relative one yields the path pattern alone, exactly as before.
47
+ *
48
+ * Every caller that builds a pathname pattern for a URL assertion or wait goes through here, so
49
+ * the host half cannot be honoured on one step form and silently dropped on another: before this,
50
+ * only `wait for [X] page` split the host, while both assertion forms matched the path against
51
+ * ANY host — `see [X] page` with `https://admin.example.com/jobs` passed on the public site.
52
+ *
53
+ * Dynamic `:id` segments still wildcard; the host stays a literal. A value that starts like an
54
+ * absolute URL but does not parse falls back to the path-only reading rather than throwing — the
55
+ * pattern is then wrong in a way the author can read, which beats losing the step.
56
+ */
57
+ function urlToPatternParts(path) {
58
+ if (!/^https?:\/\//i.test(path))
59
+ return { pathRegex: pathToRegexSource(path) };
60
+ try {
61
+ const url = new URL(path);
62
+ return { hostRegex: escapeRegex(url.hostname), pathRegex: pathToRegexSource(url.pathname) };
63
+ }
64
+ catch {
65
+ return { pathRegex: pathToRegexSource(path) };
66
+ }
67
+ }
68
+ //# sourceMappingURL=url-path-regex.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-path-regex.js","sourceRoot":"","sources":["../../src/utils/url-path-regex.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;AAKH,kCAEC;AAQD,8CAcC;AA2BD,8CAQC;AA9DD,MAAM,cAAc,GAAG,sBAAsB,CAAC;AAE9C,mEAAmE;AACnE,SAAgB,WAAW,CAAC,IAAY;IACtC,OAAO,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,IAAY;IAC5C,+FAA+F;IAC/F,6FAA6F;IAC7F,yFAAyF;IACzF,4FAA4F;IAC5F,gGAAgG;IAChG,+DAA+D;IAC/D,OAAO,IAAI;SACR,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC;SACtB,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CACf,2BAA2B,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,OAAO,CAAC,CAC3E;SACA,IAAI,CAAC,KAAK,CAAC,CAAC;AACjB,CAAC;AAUD;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,iBAAiB,CAAC,IAAY;IAC5C,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,SAAS,EAAE,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;IAC/E,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,SAAS,EAAE,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC9F,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,SAAS,EAAE,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;IAChD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-ui",
3
- "version": "3.2.35",
3
+ "version": "3.3.0-alpha.10",
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",
@@ -8,16 +8,22 @@
8
8
  "capability": "ui"
9
9
  },
10
10
  "scripts": {
11
- "build": "rm -rf dist && tsc -p tsconfig.json"
11
+ "build": "rm -rf dist && tsc -p tsconfig.json",
12
+ "prepack": "npm run build"
12
13
  },
13
14
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
15
  "license": "MIT",
15
16
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.35"
17
+ "@sun-asterisk/sungen": "3.3.0-alpha.10"
17
18
  },
18
19
  "peerDependencies": {
19
20
  "@playwright/test": "^1.57.0"
20
21
  },
22
+ "devDependencies": {
23
+ "@playwright/test": "1.63.0",
24
+ "@types/node": "^20",
25
+ "typescript": "^5"
26
+ },
21
27
  "files": [
22
28
  "dist",
23
29
  "src"
package/src/index.ts CHANGED
@@ -125,6 +125,11 @@ export function register(registry: CapabilityRegistry): void {
125
125
  gateProvider: uiGateProvider as (i: unknown) => unknown, // viewpoint coverage + depth
126
126
  sensors: [uiViewpointGateSensor],
127
127
  appProfilePresets: APP_PROFILE_PRESETS, // qa/app.yaml `extends: native|radix|antd`
128
+ // Trace target identity (SPI §4.5, DOS-14.5) — required on first use, so registering the UI
129
+ // capability never loads Playwright. A typed `require`, not `import()`: under the tsx-run CLI a
130
+ // dynamic import is real ESM and cannot resolve an extension-less TypeScript path.
131
+ traceIdentity: (input) =>
132
+ (require('./trace-identity/playwright-identity-resolver') as typeof import('./trace-identity/playwright-identity-resolver')).resolveTraceIdentities(input),
128
133
  });
129
134
  }
130
135
 
@@ -1,10 +1,9 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { formatToRegexLiteral } from '@sun-asterisk/sungen';
3
- import { StepPattern, StepTemplateData, PatternContext, MappedStep, expandRecipe, unsupportedStepCode } from '@sun-asterisk/sungen';
3
+ import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
4
4
  import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
5
5
  import { urlToPatternParts } from '../utils/url-path-regex';
6
6
  import { resolveColumnHeaderName } from '../utils/column-header';
7
- import { isDatePickerType } from './form-patterns';
8
7
 
9
8
  /**
10
9
  * Assertion patterns: visibility, text content, state, attributes
@@ -45,37 +44,6 @@ const IDENTITY_STRATEGIES = new Set([
45
44
  'accessibility-id', 'android-uiautomator', 'ios-predicate',
46
45
  ]);
47
46
 
48
- const EXPANDED_STATE_ALTERNATIVE =
49
- 'a native app exposes no expanded state - assert what the open panel shows instead (e.g. `see [X] section`)';
50
-
51
- /**
52
- * `see [X] … is expanded | is collapsed` — the assertion twin of the expand/collapse actions. Before
53
- * `expanded`/`collapsed` were state clauses the sentence fell to should-be-visible and the state
54
- * vanished: the step passed with the calendar closed (#911). Reads the SAME signal the action drives
55
- * (`expanded: { class | attribute }`, else aria-expanded), but strictly — the action's "assert only if
56
- * the attribute exists" leniency would make this assert nothing. A generator, not a resolver, because
57
- * `expanded: { state: none }` must refuse with a diagnostic rather than render a template.
58
- */
59
- function expandedStateAssertion(step: ParsedStep, context: PatternContext, state: 'expanded' | 'collapsed'): MappedStep {
60
- const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, undefined, step.elementType, step.nth);
61
- // `state: none` is the author saying the widget shows no open/closed state, so there is nothing
62
- // honest to assert — refuse rather than fall back to aria-expanded.
63
- if (resolved.expanded?.state) {
64
- const reason =
65
- `step "${step.text}" asserts [${step.selectorRef}] is ${state}, but that entry declares `
66
- + `expanded.state: ${resolved.expanded.state} (no observable state). Declare expanded.class or `
67
- + `expanded.attribute on the entry, or assert what the open panel shows instead.`;
68
- context.diagnostics?.add('SG-W017', reason, { feature: context.featureName, step: step.text });
69
- return { code: unsupportedStepCode(reason), comment: `${step.selectorRef} has no ${state} state — see SG-W017` };
70
- }
71
- return {
72
- code: context.templateEngine.renderStep('expanded-assertion', {
73
- ...resolved, selectorRef: step.selectorRef, direction: state === 'expanded' ? 'expand' : 'collapse',
74
- }),
75
- comment: `Assert ${step.selectorRef} is ${state}`,
76
- };
77
- }
78
-
79
47
  /**
80
48
  * INVARIANT — a pattern whose generated code accounts for a trailing state clause (`… is hidden`)
81
49
  * MUST declare it in `states`. Undeclared, the clause reaches SG-E021 and the step compiles to a
@@ -429,7 +397,6 @@ export const assertionPatterns: StepPattern[] = [
429
397
  ]);
430
398
  // Subset of INPUT_TYPES whose DOM implementation cannot be inferred from the token:
431
399
  // a component library may render any of these as a non-input trigger element.
432
- // (A date-picker is handled alongside, as `isDatePicker` below, #907.)
433
400
  const AMBIGUOUS_VALUE_TYPES = new Set(['dropdown', 'select', 'combobox']);
434
401
 
435
402
  // Resolve data variable value
@@ -474,24 +441,11 @@ export const assertionPatterns: StepPattern[] = [
474
441
  (AMBIGUOUS_VALUE_TYPES.has(step.elementType) ||
475
442
  ['combobox', 'listbox'].includes(resolved.role)) &&
476
443
  resolved.variant !== 'native';
477
- // A date-picker is just as ambiguous: its trigger is often a <button> opening a calendar
478
- // popup, which has no `value` and shows the chosen date as text (#907).
479
- const isDatePicker = isDatePickerType(step.elementType) && resolved.variant !== 'native';
480
444
 
481
- // `assertValue` is the third recipe intent the app-profile spec defines alongside
482
- // `fill`/`select` (spec §3.1). The lookup mirrors the fill step: a per-element `variant:`
483
- // wins for ANY element type, then the profile's widget default for the type. It used to be
484
- // consulted for dropdowns only, so a date-picker whose `fill` ran its recipe still asserted
485
- // with toHaveValue() on the button (#907).
486
- // Web only: the appium adapter ships no recipe-sequence and reads native values instead.
487
- if (context.platform !== 'mobile') {
488
- // A date-picker reads `widgets.datepicker` first — exactly as its fill does — even when it
489
- // wears `role: combobox` (the shadcn/Radix trigger), which also makes it `ambiguous`.
490
- const variantName =
491
- resolved.variant ||
492
- (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker']
493
- : ambiguous ? context.appProfile?.widgetVariants?.['select']
494
- : undefined);
445
+ if (ambiguous) {
446
+ // `assertValue` is the third recipe intent the app-profile spec defines alongside
447
+ // `fill`/`select` (spec §3.1). Same lookup shape as the select-dropdown fork.
448
+ const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
495
449
  const recipeSteps =
496
450
  variantName && variantName !== 'native'
497
451
  ? context.appProfile?.recipes?.[variantName]?.['assertValue']
@@ -515,7 +469,7 @@ export const assertionPatterns: StepPattern[] = [
515
469
  const code = context.templateEngine.renderStep('have-value-assertion', {
516
470
  ...resolved,
517
471
  dataValue,
518
- probeNative: ambiguous || isDatePicker,
472
+ probeNative: ambiguous,
519
473
  });
520
474
  return {
521
475
  code,
@@ -541,27 +495,17 @@ export const assertionPatterns: StepPattern[] = [
541
495
  // CONTAINS, not exact: an entry for a label/value pair points legitimately either at
542
496
  // the value node ("JD-001") or at the row holding both ("Job No: JD-001"), and the web
543
497
  // regex it replaces was itself anchored only at the end. `exact: true` opts into the
544
- // strict comparison. An EMPTY expected value is strict too: `toContainText('')` passes on
545
- // any text at all, so "the field is blank" would assert nothing (#910). Judged on the base
546
- // test-data value — the same compile-time read SG-W015 uses. An env overlay can change the
547
- // value at run time; a base '' with a non-empty overlay then compares exactly.
498
+ // strict comparison.
548
499
  //
549
500
  // `resolved` is the SAME lookup, already performed above — this branch used to repeat it.
550
501
  if (IDENTITY_STRATEGIES.has(resolved.strategy)) {
551
- // Empty on every value the ref takes (each @cases row, #950) → strict.
552
- const values = context.dataResolver.peekValues
553
- ? context.dataResolver.peekValues(step.dataRef, context.featureName)
554
- : [context.dataResolver.peekBaseValue?.(step.dataRef, context.featureName)].filter((v): v is string => v !== undefined);
555
- const strict = resolved.exact || (!!values?.length && values.every((v) => v === ''));
556
502
  const code = context.templateEngine.renderStep(
557
- strict ? 'have-text-assertion' : 'contain-text-assertion',
558
- // `strict` reads the BASE value at compile time; the runtime guard also covers an env
559
- // overlay that sets '' over a non-empty base (#910).
560
- { ...resolved, expectedText: dataValue, emptyMeansEmpty: true },
503
+ resolved.exact ? 'have-text-assertion' : 'contain-text-assertion',
504
+ { ...resolved, expectedText: dataValue },
561
505
  );
562
506
  return {
563
507
  code,
564
- comment: `Assert ${step.selectorRef} label ${strict ? 'has' : 'contains'} value ${step.dataRef}`,
508
+ comment: `Assert ${step.selectorRef} label ${resolved.exact ? 'has' : 'contains'} value ${step.dataRef}`,
565
509
  };
566
510
  }
567
511
 
@@ -883,28 +827,6 @@ export const assertionPatterns: StepPattern[] = [
883
827
  },
884
828
  priority: 11,
885
829
  },
886
- {
887
- name: 'is-expanded',
888
- states: ['expanded'],
889
- platforms: ['web'],
890
- platformAlternative: EXPANDED_STATE_ALTERNATIVE,
891
- // A word match like the sibling state patterns (`is-disabled`), not `step.state`: that one is set
892
- // only for a TRAILING clause, so `… is expanded # calendar open` fell back to toBeVisible().
893
- matcher: (step: ParsedStep) =>
894
- /\b(see|sees)\s+\[/.test(step.text) && /\bis expanded\b/.test(step.text) && !step.dataRef && !!step.selectorRef,
895
- generator: (step, context) => expandedStateAssertion(step, context, 'expanded'),
896
- priority: 11,
897
- },
898
- {
899
- name: 'is-collapsed',
900
- states: ['collapsed'],
901
- platforms: ['web'],
902
- platformAlternative: EXPANDED_STATE_ALTERNATIVE,
903
- matcher: (step: ParsedStep) =>
904
- /\b(see|sees)\s+\[/.test(step.text) && /\bis collapsed\b/.test(step.text) && !step.dataRef && !!step.selectorRef,
905
- generator: (step, context) => expandedStateAssertion(step, context, 'collapsed'),
906
- priority: 11,
907
- },
908
830
  {
909
831
  name: 'is-focused',
910
832
  states: ['focused'],
@@ -1,25 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
3
3
 
4
- /** A date-picker / calendar element type — the steps that read `widgets.datepicker` agree on it. */
5
- export const isDatePickerType = (elementType: string | undefined): boolean =>
6
- /date-?picker|calendar/.test(String(elementType || ''));
7
-
8
- /**
9
- * Whether an entry provably locates a `<button>`: a `button` role, or a CSS/locator selector whose
10
- * LAST compound starts with the `button` tag (`div.filters button[aria-haspopup="dialog"]`).
11
- * Attribute brackets are dropped first so a `[name="button"]` value cannot read as the tag. A
12
- * selector LIST (`input.date, button.trigger`) may match the input, so it is never called a button.
13
- */
14
- function isButtonTrigger(resolved: { strategy?: string; role?: string; value?: string }): boolean {
15
- if (resolved.strategy === 'role') return resolved.role === 'button';
16
- if (resolved.strategy !== 'css' && resolved.strategy !== 'locator') return false;
17
- const selector = String(resolved.value ?? '').replace(/\[[^\]]*\]/g, '').trim();
18
- if (selector.includes(',')) return false;
19
- const compounds = selector.split(/[\s>+~]+/);
20
- return /^button(?![\w-])/i.test(compounds[compounds.length - 1] ?? '');
21
- }
22
-
23
4
  /**
24
5
  * Form action patterns: fill, type, select, check, uncheck
25
6
  */
@@ -69,7 +50,7 @@ export const formPatterns: StepPattern[] = [
69
50
  // Widget recipe hook: per-element `variant:` wins; else, for a date-picker element,
70
51
  // the profile's `widgets.datepicker` (custom datepickers — readonly inputs that need
71
52
  // open-panel choreography). Resolves to a recipe with a `fill` intent.
72
- const isDatePicker = isDatePickerType(step.elementType);
53
+ const isDatePicker = /date-?picker|calendar/.test(String(step.elementType || ''));
73
54
  const fillVariant = resolved.variant || (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker'] : undefined);
74
55
  const fillRecipe = fillVariant ? context.appProfile?.recipes?.[fillVariant]?.['fill'] : undefined;
75
56
  if (fillRecipe) {
@@ -111,25 +92,6 @@ export const formPatterns: StepPattern[] = [
111
92
  };
112
93
  }
113
94
 
114
- // A web date-picker whose trigger is a BUTTON, with no recipe: `.fill()` throws "Element is
115
- // not an <input>" on it, and there is no value to type — the date is chosen in the calendar
116
- // the button opens. Refuse at compile time and name the recipe to declare (#907). Only the
117
- // shapes that are provably a button are refused; any other locator may still be a native
118
- // `<input type="date">`, where the generic fill is right.
119
- if (isDatePicker && isButtonTrigger(resolved)) {
120
- const reason =
121
- `step "${step.text}" fills [${step.selectorRef}] date-picker, but that entry locates a button `
122
- + `(a calendar trigger) and no recipe drives it — fill() cannot type into a button. Declare a `
123
- + `recipe with a fill intent in qa/app.yaml and point the entry at it with variant: <recipe> `
124
- + `(or set widgets.datepicker), or tag this scenario @manual.`;
125
- context.diagnostics?.add('SG-W017', reason, { feature: context.featureName, step: step.text });
126
- return {
127
- templateName: 'date-picker-unsupported',
128
- data: { reason },
129
- comment: `date-picker trigger is a button with no fill recipe — see SG-W017`,
130
- };
131
- }
132
-
133
95
  // Rich-text editors fill with pressSequentially, not .fill(). Two ways in: the selector
134
96
  // entry (`contenteditable: true`, which the resolver translates into `inputMethod`, #712 —
135
97
  // or `inputMethod` written out), and the Gherkin `editor` element type, which is a property
@@ -142,16 +104,9 @@ export const formPatterns: StepPattern[] = [
142
104
  const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
143
105
  const templateName = isEditor ? 'fill-editor-action' : 'fill-action';
144
106
 
145
- // `.fill()` only works on an input / textarea / select / contenteditable. A date-picker whose
146
- // trigger is a <button> opening a calendar popup (Radix Popover + React Day Picker), or an
147
- // entry that is plainly a button or link, fails with Playwright's "Element is not an <input>"
148
- // — which says nothing about the fix. Guard those at runtime with the way forward: a recipe
149
- // with a `fill` intent (#907). A locator's tag is unknown at compile time, hence a probe.
150
- const guardFillable = !isEditor && (isDatePicker || ['button', 'link'].includes(String(resolved.role || '')));
151
-
152
107
  return {
153
108
  templateName,
154
- data: { ...resolved, selectorRef: step.selectorRef, fillValue: value, ...(guardFillable ? { guardFillable: true } : {}) },
109
+ data: { ...resolved, selectorRef: step.selectorRef, fillValue: value },
155
110
  comment: isEditor
156
111
  ? `Fill rich text editor ${step.selectorRef} with ${step.dataRef || step.value}`
157
112
  : `Fill ${step.selectorRef} with ${step.dataRef || step.value}`,
@@ -234,21 +234,9 @@ export const tablePatterns: StepPattern[] = [
234
234
  !/\bis\s+hidden\b/i.test(step.text);
235
235
  },
236
236
  resolver: (step, context) => {
237
- // Decide by the literal value(s) (runtime mode returns an opaque marker from resolveData). In
238
- // @cases a dataset column has one value per row: it is a count only when EVERY row is a
239
- // number (#950). Mixed rows compile to text — and say so, since some rows read as counts.
240
- const values = context.dataResolver.peekValues
241
- ? context.dataResolver.peekValues(step.dataRef!, context.featureName)
242
- : [context.dataResolver.peekBaseValue?.(step.dataRef!, context.featureName)].filter((v): v is string => v !== undefined);
243
- const isCount = (v: string) => /^\d+$/.test(v.trim());
244
- if (!values?.length || !values.every(isCount)) {
245
- if (values?.some(isCount)) {
246
- context.diagnostics?.add('SG-W015',
247
- `\`see [${step.selectorRef}] row with {{${step.dataRef}}}\` mixes numbers and text across the @cases rows, so it compiles to a TEXT assertion, not a row count. Split the counting rows into their own @cases scenario.`,
248
- { feature: context.featureName, step: step.text });
249
- }
250
- return null as any; // not numeric → fall through to text semantics
251
- }
237
+ // Decide by the BASE literal (runtime mode returns an opaque marker from resolveData).
238
+ const base = context.dataResolver.peekBaseValue?.(step.dataRef!, context.featureName);
239
+ if (base === undefined || !/^\d+$/.test(String(base).trim())) return null as any; // not numeric → fall through to text semantics
252
240
  const resolved = context.selectorResolver.resolveSelector(
253
241
  step.selectorRef!, context.featureName, 'row', step.nth
254
242
  );
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The head line of a Playwright `locator.ariaSnapshot()` → role, accessible name, heading level.
3
+ *
4
+ * Owned here on purpose (G5): the spike loaded Playwright's private aria-YAML parser by absolute
5
+ * path, which breaks on any internal move. We only ever need the FIRST node of a single element's
6
+ * snapshot, whose grammar is small and documented in Playwright's aria snapshot docs:
7
+ *
8
+ * - button "Login"
9
+ * - heading "My Account" [level=1]
10
+ * - textbox "Email": test@playground.dev (a value after the colon)
11
+ * - link "Products": (children on the next lines)
12
+ * - text: Hi, Jane (a bare text node — no role)
13
+ *
14
+ * More than one top-level node (a fragment) has no single identity.
15
+ */
16
+ export interface AriaHead { role: string; name: string; level?: number; note?: string }
17
+
18
+ const LINE = /^- ([a-zA-Z]+)(?: "((?:[^"\\]|\\.)*)")?((?: \[[^\]]*\])*)(?::.*)?$/;
19
+
20
+ function unescape(raw: string): string {
21
+ try { return JSON.parse(`"${raw}"`) as string; } catch { return raw.replace(/\\"/g, '"'); }
22
+ }
23
+
24
+ export function parseAriaHead(yaml: string): AriaHead {
25
+ const top = yaml.split(/\r?\n/).filter((l) => l.startsWith('- '));
26
+ if (top.length !== 1) return { role: 'generic', name: '', note: `fragment of ${top.length}` };
27
+ const m = LINE.exec(top[0].trimEnd());
28
+ if (!m) return { role: 'generic', name: '', note: `unparsed: ${top[0].slice(0, 60)}` };
29
+ const role = m[1];
30
+ if (role === 'text') return { role: 'text', name: '', note: 'text node' };
31
+ const level = /\[level=(\d)\]/.exec(m[3] ?? '')?.[1];
32
+ return { role, name: m[2] === undefined ? '' : unescape(m[2]), ...(level ? { level: Number(level) } : {}) };
33
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `traceIdentity` for the UI capability (SPI §4.5, DOS-14.5): who a recorded action was aimed at,
3
+ * from Playwright's OWN accessibility engine on the replayed snapshot — the phase 07 method that
4
+ * agreed with the test's locator on 98/98 oracle-bearing actions over two real sites.
5
+ *
6
+ * replay the inert HTML core rendered, in a context with JavaScript disabled, offline,
7
+ * service workers blocked, every request routed to abort AND Chromium's network
8
+ * blocklist set to `*` (non-http schemes too) — nothing leaves the machine
9
+ * identity role + name from `ariaSnapshot()` of the element Playwright marked, re-resolved with
10
+ * `getByRole(role, { name, exact })`: it must land on exactly that element
11
+ * G3 if role+name matches several elements, look for a named scope (row, landmark,
12
+ * dialog, card…) inside which it is unique; none → `low` (ambiguous)
13
+ * low any reason at all: core's caps (missing CSS, broken refs), no semantic role, empty
14
+ * name, getByRole missing the target, ambiguity, a failed snapshot
15
+ * A missing browser is an ERROR, never a silent `low` for everything.
16
+ */
17
+ import { chromium, type Browser, type Locator, type Page } from '@playwright/test';
18
+ import type { CapabilityDescriptor } from '@sun-asterisk/sungen';
19
+ import { parseAriaHead } from './aria-snapshot-head';
20
+
21
+ type Resolver = NonNullable<CapabilityDescriptor['traceIdentity']>;
22
+ type Input = Parameters<Resolver>[0];
23
+ type Output = Awaited<ReturnType<Resolver>>;
24
+ type Result = Output['results'][number];
25
+
26
+ const NAMELESS = new Set(['generic', 'none', 'presentation', 'text', 'fragment', 'paragraph']);
27
+ const SCOPE_ROLES = ['row', 'listitem', 'article', 'dialog', 'alertdialog', 'form', 'group', 'tabpanel', 'region', 'navigation', 'complementary', 'search', 'banner', 'contentinfo', 'main', 'menu', 'list', 'table', 'grid'];
28
+
29
+ export const RESOLVER_ID = 'driver-ui/playwright-aria@1';
30
+
31
+ /** At most this many snapshots per call — a bound on work, not a sampling. */
32
+ export const MAX_REQUESTS = 2000;
33
+
34
+ async function launch(): Promise<Browser> {
35
+ try { return await chromium.launch({ headless: true }); } catch (e) {
36
+ throw new Error(`trace identity needs Chromium for Playwright — run \`npx playwright install chromium\` (${(e as Error).message.split('\n')[0]})`);
37
+ }
38
+ }
39
+
40
+ /** A fresh page in a fresh isolated context: JS off, offline, SW blocked, every request aborted, CDP blocklist `*`. */
41
+ async function isolatedPage(browser: Browser, counter: { blocked: number }): Promise<Page> {
42
+ const context = await browser.newContext({ javaScriptEnabled: false, offline: true, serviceWorkers: 'block' });
43
+ await context.route('**/*', (route) => route.abort('blockedbyclient'));
44
+ const page = await context.newPage();
45
+ page.on('request', () => { counter.blocked++; });
46
+ const cdp = await context.newCDPSession(page);
47
+ await cdp.send('Network.enable');
48
+ await cdp.send('Network.setBlockedURLs', { urls: ['*'] });
49
+ return page;
50
+ }
51
+
52
+ const byRole = (root: Page | Locator, role: string, name: string, level?: number): Locator =>
53
+ root.getByRole(role as Parameters<Page['getByRole']>[0], { ...(name ? { name, exact: true } : {}), ...(level ? { level } : {}) });
54
+
55
+ /** A named container in which role+name is unique and lands on the target (G3). */
56
+ async function scopeFor(page: Page, target: Locator, role: string, name: string, level?: number): Promise<Result['scope'] | undefined> {
57
+ for (const scopeRole of SCOPE_ROLES) {
58
+ const holders = byRole(page, scopeRole, '').filter({ has: target });
59
+ const n = await holders.count();
60
+ if (!n) continue;
61
+ const head = parseAriaHead((await holders.nth(n - 1).ariaSnapshot({ timeout: 3_000 }).catch(() => '')).split('\n')[0]);
62
+ if (!head.name) continue;
63
+ const scoped = byRole(byRole(page, scopeRole, head.name), role, name, level);
64
+ if ((await scoped.count()) === 1 && (await scoped.and(target).count()) === 1) return { role: scopeRole, name: head.name };
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ const lowResult = (key: string, reasons: string[]): Result => ({ key, role: 'generic', name: '', identityConfidence: 'low', reasons });
70
+
71
+ async function identify(page: Page, req: Input['requests'][number]): Promise<Result> {
72
+ const low = (reasons: string[]): Result => lowResult(req.key, reasons);
73
+ await page.setViewportSize({ width: Math.max(1, req.viewport.width), height: Math.max(1, req.viewport.height) });
74
+ await page.setContent(req.html, { waitUntil: 'domcontentloaded', timeout: 10_000 });
75
+ const target = page.locator(req.targetSelector);
76
+ const marked = await target.count();
77
+ if (marked !== 1) return low([...req.capReasons, `marked-elements=${marked}`]);
78
+ let aria: string;
79
+ try { aria = await target.ariaSnapshot({ timeout: 3_000 }); } catch (e) { return low([...req.capReasons, `ariaSnapshot-failed: ${(e as Error).message.split('\n')[0]}`]); }
80
+ const head = parseAriaHead(aria);
81
+ const id = { role: head.role, name: head.name, ...(head.level ? { level: head.level } : {}) };
82
+ const reasons = [...req.capReasons];
83
+ if (NAMELESS.has(head.role)) reasons.push(`no-semantic-role(${head.role})`);
84
+ if (!head.name) reasons.push('empty-name');
85
+ let scope: Result['scope'];
86
+ if (!NAMELESS.has(head.role)) {
87
+ const all = byRole(page, head.role, head.name, head.level);
88
+ const count = await all.count();
89
+ if ((await all.and(target).count()) !== 1) reasons.push('getByRole-misses-target');
90
+ else if (count > 1) {
91
+ scope = head.name ? await scopeFor(page, target, head.role, head.name, head.level) : undefined;
92
+ if (!scope) reasons.push(`ambiguous(${count})`);
93
+ }
94
+ }
95
+ return reasons.length ? { key: req.key, ...id, identityConfidence: 'low', reasons } : { key: req.key, ...id, ...(scope ? { scope } : {}), identityConfidence: 'high', reasons: [] };
96
+ }
97
+
98
+ export async function resolveTraceIdentities(input: Input): Promise<Output> {
99
+ const requests = Array.isArray(input?.requests) ? input.requests : [];
100
+ if (requests.length > MAX_REQUESTS) throw new Error(`trace identity: ${requests.length} snapshots in one call (max ${MAX_REQUESTS})`);
101
+ const browser = await launch();
102
+ try {
103
+ const counter = { blocked: 0 };
104
+ let page = await isolatedPage(browser, counter);
105
+ const results: Result[] = [];
106
+ for (const req of requests) {
107
+ try {
108
+ results.push(await identify(page, req));
109
+ } catch (e) {
110
+ // One bad snapshot is a `low` answer for that snapshot, never a crash of the whole check.
111
+ const why = (e as Error).message.split('\n')[0].slice(0, 200);
112
+ results.push(lowResult(req.key, [`replay-failed: ${why}`]));
113
+ if (page.isClosed() || /closed/i.test(why)) {
114
+ await page.context().close().catch(() => undefined);
115
+ page = await isolatedPage(browser, counter);
116
+ }
117
+ }
118
+ }
119
+ return { resolver: RESOLVER_ID, browser: `chromium ${browser.version()}`, results, blockedRequests: counter.blocked };
120
+ } finally {
121
+ await browser.close();
122
+ }
123
+ }