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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/app-profile-presets.d.ts +12 -0
  2. package/dist/app-profile-presets.d.ts.map +1 -0
  3. package/dist/app-profile-presets.js +85 -0
  4. package/dist/app-profile-presets.js.map +1 -0
  5. package/dist/index.d.ts +37 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +93 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/patterns/assertion-patterns.d.ts +13 -0
  10. package/dist/patterns/assertion-patterns.d.ts.map +1 -0
  11. package/dist/patterns/assertion-patterns.js +903 -0
  12. package/dist/patterns/assertion-patterns.js.map +1 -0
  13. package/dist/patterns/capture-patterns.d.ts +21 -0
  14. package/dist/patterns/capture-patterns.d.ts.map +1 -0
  15. package/dist/patterns/capture-patterns.js +174 -0
  16. package/dist/patterns/capture-patterns.js.map +1 -0
  17. package/dist/patterns/focus-patterns.d.ts +22 -0
  18. package/dist/patterns/focus-patterns.d.ts.map +1 -0
  19. package/dist/patterns/focus-patterns.js +79 -0
  20. package/dist/patterns/focus-patterns.js.map +1 -0
  21. package/dist/patterns/form-patterns.d.ts +6 -0
  22. package/dist/patterns/form-patterns.d.ts.map +1 -0
  23. package/dist/patterns/form-patterns.js +239 -0
  24. package/dist/patterns/form-patterns.js.map +1 -0
  25. package/dist/patterns/interaction-patterns.d.ts +20 -0
  26. package/dist/patterns/interaction-patterns.d.ts.map +1 -0
  27. package/dist/patterns/interaction-patterns.js +633 -0
  28. package/dist/patterns/interaction-patterns.js.map +1 -0
  29. package/dist/patterns/keyboard-patterns.d.ts +7 -0
  30. package/dist/patterns/keyboard-patterns.d.ts.map +1 -0
  31. package/dist/patterns/keyboard-patterns.js +57 -0
  32. package/dist/patterns/keyboard-patterns.js.map +1 -0
  33. package/dist/patterns/navigation-patterns.d.ts +6 -0
  34. package/dist/patterns/navigation-patterns.d.ts.map +1 -0
  35. package/dist/patterns/navigation-patterns.js +185 -0
  36. package/dist/patterns/navigation-patterns.js.map +1 -0
  37. package/dist/patterns/scope-patterns.d.ts +7 -0
  38. package/dist/patterns/scope-patterns.d.ts.map +1 -0
  39. package/dist/patterns/scope-patterns.js +36 -0
  40. package/dist/patterns/scope-patterns.js.map +1 -0
  41. package/dist/patterns/scroll-patterns.d.ts +7 -0
  42. package/dist/patterns/scroll-patterns.d.ts.map +1 -0
  43. package/dist/patterns/scroll-patterns.js +25 -0
  44. package/dist/patterns/scroll-patterns.js.map +1 -0
  45. package/dist/patterns/setup-patterns.d.ts +6 -0
  46. package/dist/patterns/setup-patterns.d.ts.map +1 -0
  47. package/dist/patterns/setup-patterns.js +75 -0
  48. package/dist/patterns/setup-patterns.js.map +1 -0
  49. package/dist/patterns/storage-patterns.d.ts +26 -0
  50. package/dist/patterns/storage-patterns.d.ts.map +1 -0
  51. package/dist/patterns/storage-patterns.js +142 -0
  52. package/dist/patterns/storage-patterns.js.map +1 -0
  53. package/dist/patterns/table-pagination-patterns.d.ts +42 -0
  54. package/dist/patterns/table-pagination-patterns.d.ts.map +1 -0
  55. package/dist/patterns/table-pagination-patterns.js +93 -0
  56. package/dist/patterns/table-pagination-patterns.js.map +1 -0
  57. package/dist/patterns/table-patterns.d.ts +19 -0
  58. package/dist/patterns/table-patterns.d.ts.map +1 -0
  59. package/dist/patterns/table-patterns.js +594 -0
  60. package/dist/patterns/table-patterns.js.map +1 -0
  61. package/dist/trace-identity/aria-snapshot-head.d.ts +23 -0
  62. package/dist/trace-identity/aria-snapshot-head.d.ts.map +1 -0
  63. package/dist/trace-identity/aria-snapshot-head.js +26 -0
  64. package/dist/trace-identity/aria-snapshot-head.js.map +1 -0
  65. package/dist/trace-identity/playwright-identity-resolver.d.ts +10 -0
  66. package/dist/trace-identity/playwright-identity-resolver.d.ts.map +1 -0
  67. package/dist/trace-identity/playwright-identity-resolver.js +130 -0
  68. package/dist/trace-identity/playwright-identity-resolver.js.map +1 -0
  69. package/dist/trace-identity/trace-run-record-reporter.d.ts +41 -0
  70. package/dist/trace-identity/trace-run-record-reporter.d.ts.map +1 -0
  71. package/dist/trace-identity/trace-run-record-reporter.js +113 -0
  72. package/dist/trace-identity/trace-run-record-reporter.js.map +1 -0
  73. package/dist/utils/column-header.d.ts +37 -0
  74. package/dist/utils/column-header.d.ts.map +1 -0
  75. package/dist/utils/column-header.js +46 -0
  76. package/dist/utils/column-header.js.map +1 -0
  77. package/dist/utils/row-index.d.ts +18 -0
  78. package/dist/utils/row-index.d.ts.map +1 -0
  79. package/dist/utils/row-index.js +39 -0
  80. package/dist/utils/row-index.js.map +1 -0
  81. package/dist/utils/url-path-regex.d.ts +45 -0
  82. package/dist/utils/url-path-regex.d.ts.map +1 -0
  83. package/dist/utils/url-path-regex.js +68 -0
  84. package/dist/utils/url-path-regex.js.map +1 -0
  85. package/package.json +7 -3
  86. package/src/index.ts +10 -1
  87. package/src/trace-identity/aria-snapshot-head.ts +33 -0
  88. package/src/trace-identity/playwright-identity-resolver.ts +123 -0
  89. package/src/trace-identity/trace-run-record-reporter.ts +105 -0
@@ -0,0 +1,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.34",
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.34"
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;