@sungen/driver-ui 3.2.34 → 3.3.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +41 -0
- package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
- package/dist/trace-identity/trace-run-record-reporter.js +113 -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 +7 -3
- package/src/index.ts +10 -1
- 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 +105 -0
|
@@ -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
|
|
3
|
+
"version": "3.3.0-alpha.2",
|
|
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,20 @@
|
|
|
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
|
|
17
|
+
"@sun-asterisk/sungen": "3.3.0-alpha.2"
|
|
17
18
|
},
|
|
18
19
|
"peerDependencies": {
|
|
19
20
|
"@playwright/test": "^1.57.0"
|
|
20
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@playwright/test": "1.63.0"
|
|
24
|
+
},
|
|
21
25
|
"files": [
|
|
22
26
|
"dist",
|
|
23
27
|
"src"
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,10 @@ export interface UiGateInput {
|
|
|
47
47
|
viewpoints: ViewpointEntry[];
|
|
48
48
|
catalog: Catalog;
|
|
49
49
|
focus: Parameters<typeof assertionDepth>[2];
|
|
50
|
+
/** `platform: mobile` — makes `mobile-*` page types candidates (#864). */
|
|
51
|
+
isMobile?: boolean;
|
|
52
|
+
/** The viewpoint text, so a declared `page-type:` wins over keyword detection (#864). */
|
|
53
|
+
viewpointText?: string;
|
|
50
54
|
}
|
|
51
55
|
export interface UiGateResult {
|
|
52
56
|
gate: GateResult;
|
|
@@ -58,7 +62,7 @@ export interface UiGateResult {
|
|
|
58
62
|
* functions verbatim, so the produced gate/depth (and therefore the score) are byte-identical.
|
|
59
63
|
*/
|
|
60
64
|
const uiGateProvider = (input: UiGateInput): UiGateResult => {
|
|
61
|
-
const gate = viewpointGate(input.scenarios, input.viewpoints, input.catalog);
|
|
65
|
+
const gate = viewpointGate(input.scenarios, input.viewpoints, input.catalog, input.isMobile ?? false, input.viewpointText ?? '');
|
|
62
66
|
const depth = assertionDepth(input.scenarios, dataThemesFor(input.catalog, gate.pageType), input.focus);
|
|
63
67
|
return { gate, depth };
|
|
64
68
|
};
|
|
@@ -121,6 +125,11 @@ export function register(registry: CapabilityRegistry): void {
|
|
|
121
125
|
gateProvider: uiGateProvider as (i: unknown) => unknown, // viewpoint coverage + depth
|
|
122
126
|
sensors: [uiViewpointGateSensor],
|
|
123
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),
|
|
124
133
|
});
|
|
125
134
|
}
|
|
126
135
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright reporter that records sungen's own RUN RECORD for trace conformance (DOS-14.6, G5).
|
|
3
|
+
*
|
|
4
|
+
* A Playwright trace carries no run id and no hash of the code that produced it, so a good trace of
|
|
5
|
+
* another test — or of the same test before the code was weakened — could be swapped in. This
|
|
6
|
+
* reporter writes, next to the traces it vouches for (`<outputDir>/sungen-run-record[.shard-i-of-n].json`):
|
|
7
|
+
*
|
|
8
|
+
* runId SUNGEN_RUN_ID when set (one id across CI shards), else a fresh UUID
|
|
9
|
+
* specs sha256 of every test file at run START (the code that ran)
|
|
10
|
+
* attempts per test attempt: testId, retry, status, window, and the sha256 of its trace.zip
|
|
11
|
+
* report where the JSON reporter wrote its report — traces are mapped THROUGH that report
|
|
12
|
+
* (merge-reports renames trace files; their content, and so the sha256, stays)
|
|
13
|
+
*
|
|
14
|
+
* The outputDir is wiped at the start of every Playwright run, so a record and its traces always
|
|
15
|
+
* come from the same run. Enable it for free / impl:code tests together with `trace: 'on'`, in a
|
|
16
|
+
* config with its OWN outputDir — the compiled suite clears `test-results/` on every run (#941):
|
|
17
|
+
*
|
|
18
|
+
* outputDir: 'test-results-free',
|
|
19
|
+
* reporter: [['json', { outputFile: 'test-results-free/results.json' }],
|
|
20
|
+
* ['@sungen/driver-ui/dist/trace-identity/trace-run-record-reporter.js']],
|
|
21
|
+
* (the compiled file — Playwright does not transpile TypeScript inside node_modules; `prepack` builds it)
|
|
22
|
+
* use: { trace: 'on' },
|
|
23
|
+
*/
|
|
24
|
+
import type { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
|
25
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
|
|
29
|
+
interface AttemptRecord {
|
|
30
|
+
testId: string; file: string; title: string; retry: number; status: string;
|
|
31
|
+
startTime: string; duration: number; trace: { sha256: string } | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sha256File = (file: string): string | null => {
|
|
35
|
+
try { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return null; }
|
|
36
|
+
};
|
|
37
|
+
const posix = (p: string): string => p.split(path.sep).join('/');
|
|
38
|
+
|
|
39
|
+
class TraceRunRecordReporter implements Reporter {
|
|
40
|
+
private config?: FullConfig;
|
|
41
|
+
private root = process.cwd();
|
|
42
|
+
private runId = process.env.SUNGEN_RUN_ID || randomUUID();
|
|
43
|
+
private startedAt = new Date().toISOString();
|
|
44
|
+
private specs: Record<string, string> = {};
|
|
45
|
+
private results: Array<{ test: TestCase; result: TestResult }> = [];
|
|
46
|
+
|
|
47
|
+
printsToStdio(): boolean { return false; }
|
|
48
|
+
|
|
49
|
+
onBegin(config: FullConfig, suite: Suite): void {
|
|
50
|
+
this.config = config;
|
|
51
|
+
if (config.configFile) this.root = path.dirname(config.configFile);
|
|
52
|
+
for (const test of suite.allTests()) {
|
|
53
|
+
const rel = this.rel(test.location.file);
|
|
54
|
+
if (this.specs[rel] === undefined) this.specs[rel] = sha256File(test.location.file) ?? '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onTestEnd(test: TestCase, result: TestResult): void {
|
|
59
|
+
this.results.push({ test, result });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async onEnd(): Promise<void> {
|
|
63
|
+
if (!this.config) return;
|
|
64
|
+
const attempts: AttemptRecord[] = this.results.map(({ test, result }) => {
|
|
65
|
+
const trace = result.attachments.find((a) => a.name === 'trace' && a.path);
|
|
66
|
+
const sha = trace?.path ? sha256File(trace.path) : null;
|
|
67
|
+
return {
|
|
68
|
+
testId: test.id, file: this.rel(test.location.file), title: test.title, retry: result.retry, status: result.status,
|
|
69
|
+
startTime: result.startTime.toISOString(), duration: result.duration, trace: sha ? { sha256: sha } : null,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
const shard = this.config.shard;
|
|
73
|
+
const record = {
|
|
74
|
+
schema: 1, tool: 'sungen-trace-run-record', runId: this.runId, startedAt: this.startedAt,
|
|
75
|
+
playwrightVersion: this.config.version, shard: shard ? `${shard.current}/${shard.total}` : null,
|
|
76
|
+
report: this.jsonReportPath(), specs: this.specs, attempts,
|
|
77
|
+
};
|
|
78
|
+
const outDir = this.config.projects[0]?.outputDir ?? path.join(this.root, 'test-results');
|
|
79
|
+
const name = shard ? `sungen-run-record.shard-${shard.current}-of-${shard.total}.json` : 'sungen-run-record.json';
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(outDir, name), JSON.stringify(record, null, 2));
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// A missing record makes every free / impl:code test `unproven` — say so instead of hiding it.
|
|
85
|
+
console.error(`[sungen] could not write the trace run record: ${e instanceof Error ? e.message : String(e)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The JSON reporter's output file, as the reporter itself resolves it (option, then env). */
|
|
90
|
+
private jsonReportPath(): string | null {
|
|
91
|
+
const json = this.config?.reporter.find(([name]) => name === 'json');
|
|
92
|
+
const opt = json && typeof (json[1] as { outputFile?: unknown })?.outputFile === 'string' ? (json[1] as { outputFile: string }).outputFile : undefined;
|
|
93
|
+
const fromEnv = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE
|
|
94
|
+
?? (process.env.PLAYWRIGHT_JSON_OUTPUT_NAME ? path.join(process.env.PLAYWRIGHT_JSON_OUTPUT_DIR ?? '', process.env.PLAYWRIGHT_JSON_OUTPUT_NAME) : undefined);
|
|
95
|
+
const chosen = opt ? path.resolve(this.root, opt) : fromEnv ? path.resolve(process.cwd(), fromEnv) : undefined;
|
|
96
|
+
return chosen ? this.rel(chosen) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private rel(file: string): string {
|
|
100
|
+
const r = path.relative(this.root, file);
|
|
101
|
+
return posix(r.startsWith('..') || path.isAbsolute(r) ? path.basename(file) : r);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default TraceRunRecordReporter;
|