@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.
- package/dist/app-profile-presets.d.ts +12 -0
- package/dist/app-profile-presets.d.ts.map +1 -0
- package/dist/app-profile-presets.js +85 -0
- package/dist/app-profile-presets.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/assertion-patterns.d.ts +13 -0
- package/dist/patterns/assertion-patterns.d.ts.map +1 -0
- package/dist/patterns/assertion-patterns.js +903 -0
- package/dist/patterns/assertion-patterns.js.map +1 -0
- package/dist/patterns/capture-patterns.d.ts +21 -0
- package/dist/patterns/capture-patterns.d.ts.map +1 -0
- package/dist/patterns/capture-patterns.js +174 -0
- package/dist/patterns/capture-patterns.js.map +1 -0
- package/dist/patterns/focus-patterns.d.ts +22 -0
- package/dist/patterns/focus-patterns.d.ts.map +1 -0
- package/dist/patterns/focus-patterns.js +79 -0
- package/dist/patterns/focus-patterns.js.map +1 -0
- package/dist/patterns/form-patterns.d.ts +6 -0
- package/dist/patterns/form-patterns.d.ts.map +1 -0
- package/dist/patterns/form-patterns.js +239 -0
- package/dist/patterns/form-patterns.js.map +1 -0
- package/dist/patterns/interaction-patterns.d.ts +20 -0
- package/dist/patterns/interaction-patterns.d.ts.map +1 -0
- package/dist/patterns/interaction-patterns.js +633 -0
- package/dist/patterns/interaction-patterns.js.map +1 -0
- package/dist/patterns/keyboard-patterns.d.ts +7 -0
- package/dist/patterns/keyboard-patterns.d.ts.map +1 -0
- package/dist/patterns/keyboard-patterns.js +57 -0
- package/dist/patterns/keyboard-patterns.js.map +1 -0
- package/dist/patterns/navigation-patterns.d.ts +6 -0
- package/dist/patterns/navigation-patterns.d.ts.map +1 -0
- package/dist/patterns/navigation-patterns.js +185 -0
- package/dist/patterns/navigation-patterns.js.map +1 -0
- package/dist/patterns/scope-patterns.d.ts +7 -0
- package/dist/patterns/scope-patterns.d.ts.map +1 -0
- package/dist/patterns/scope-patterns.js +36 -0
- package/dist/patterns/scope-patterns.js.map +1 -0
- package/dist/patterns/scroll-patterns.d.ts +7 -0
- package/dist/patterns/scroll-patterns.d.ts.map +1 -0
- package/dist/patterns/scroll-patterns.js +25 -0
- package/dist/patterns/scroll-patterns.js.map +1 -0
- package/dist/patterns/setup-patterns.d.ts +6 -0
- package/dist/patterns/setup-patterns.d.ts.map +1 -0
- package/dist/patterns/setup-patterns.js +75 -0
- package/dist/patterns/setup-patterns.js.map +1 -0
- package/dist/patterns/storage-patterns.d.ts +26 -0
- package/dist/patterns/storage-patterns.d.ts.map +1 -0
- package/dist/patterns/storage-patterns.js +142 -0
- package/dist/patterns/storage-patterns.js.map +1 -0
- package/dist/patterns/table-pagination-patterns.d.ts +42 -0
- package/dist/patterns/table-pagination-patterns.d.ts.map +1 -0
- package/dist/patterns/table-pagination-patterns.js +93 -0
- package/dist/patterns/table-pagination-patterns.js.map +1 -0
- package/dist/patterns/table-patterns.d.ts +19 -0
- package/dist/patterns/table-patterns.d.ts.map +1 -0
- package/dist/patterns/table-patterns.js +594 -0
- package/dist/patterns/table-patterns.js.map +1 -0
- package/dist/trace-identity/aria-snapshot-head.d.ts +23 -0
- package/dist/trace-identity/aria-snapshot-head.d.ts.map +1 -0
- package/dist/trace-identity/aria-snapshot-head.js +26 -0
- package/dist/trace-identity/aria-snapshot-head.js.map +1 -0
- package/dist/trace-identity/playwright-identity-resolver.d.ts +10 -0
- package/dist/trace-identity/playwright-identity-resolver.d.ts.map +1 -0
- package/dist/trace-identity/playwright-identity-resolver.js +130 -0
- package/dist/trace-identity/playwright-identity-resolver.js.map +1 -0
- package/dist/trace-identity/trace-run-record-reporter.d.ts +50 -0
- package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
- package/dist/trace-identity/trace-run-record-reporter.js +146 -0
- package/dist/trace-identity/trace-run-record-reporter.js.map +1 -0
- package/dist/utils/column-header.d.ts +37 -0
- package/dist/utils/column-header.d.ts.map +1 -0
- package/dist/utils/column-header.js +46 -0
- package/dist/utils/column-header.js.map +1 -0
- package/dist/utils/row-index.d.ts +18 -0
- package/dist/utils/row-index.d.ts.map +1 -0
- package/dist/utils/row-index.js +39 -0
- package/dist/utils/row-index.js.map +1 -0
- package/dist/utils/url-path-regex.d.ts +45 -0
- package/dist/utils/url-path-regex.d.ts.map +1 -0
- package/dist/utils/url-path-regex.js +68 -0
- package/dist/utils/url-path-regex.js.map +1 -0
- package/package.json +9 -3
- package/src/index.ts +5 -0
- package/src/patterns/assertion-patterns.ts +10 -88
- package/src/patterns/form-patterns.ts +2 -47
- package/src/patterns/table-patterns.ts +3 -15
- package/src/trace-identity/aria-snapshot-head.ts +33 -0
- package/src/trace-identity/playwright-identity-resolver.ts +123 -0
- 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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
558
|
-
|
|
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 ${
|
|
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 =
|
|
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
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|