@michaelmagan/dnumb 0.1.0
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/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- package/skills/dnumb/SKILL.md +136 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** `overflow` values that actually clip. Everything else lets content escape. */
|
|
2
|
+
const CLIPS = /^(hidden|scroll|auto|clip|overlay)$/;
|
|
3
|
+
/** …and of those, the ones a user can scroll to reveal what is hidden. */
|
|
4
|
+
const REACHABLE = /^(auto|scroll|overlay)$/;
|
|
5
|
+
/**
|
|
6
|
+
* The nearest ancestor that establishes this element's containing block.
|
|
7
|
+
*
|
|
8
|
+
* - `position: fixed` escapes every scroll container: its containing block is
|
|
9
|
+
* the viewport, so nothing in the DOM chain clips it.
|
|
10
|
+
* - `position: absolute` skips ancestors that are `position: static`.
|
|
11
|
+
* - everything else is clipped by its DOM ancestors, in order.
|
|
12
|
+
*/
|
|
13
|
+
function* clippingAncestors(el, byId) {
|
|
14
|
+
if (el.position === 'fixed')
|
|
15
|
+
return;
|
|
16
|
+
const absolute = el.position === 'absolute';
|
|
17
|
+
let cursor = el.parentId;
|
|
18
|
+
while (cursor) {
|
|
19
|
+
const ancestor = byId.get(cursor);
|
|
20
|
+
if (!ancestor)
|
|
21
|
+
return;
|
|
22
|
+
if (!absolute || ancestor.position !== 'static')
|
|
23
|
+
yield ancestor;
|
|
24
|
+
cursor = ancestor.parentId;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function overflowLabel(el) {
|
|
28
|
+
return el.overflowX === el.overflowY ? el.overflowX : `${el.overflowX}/${el.overflowY}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* How far `el` extends past `clipper` on each side. All four are >= 0; a zero
|
|
32
|
+
* on every side means the element is fully inside.
|
|
33
|
+
*/
|
|
34
|
+
function hiddenExtents(el, clipper) {
|
|
35
|
+
return {
|
|
36
|
+
top: Math.max(0, clipper.rect.y - el.rect.y),
|
|
37
|
+
left: Math.max(0, clipper.rect.x - el.rect.x),
|
|
38
|
+
bottom: Math.max(0, el.rect.y + el.rect.height - (clipper.rect.y + clipper.rect.height)),
|
|
39
|
+
right: Math.max(0, el.rect.x + el.rect.width - (clipper.rect.x + clipper.rect.width)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The clip fact for one element, or `undefined` when nothing hides any of it.
|
|
44
|
+
*
|
|
45
|
+
* The returned fact deliberately carries `overflow` and `scrollable` beside the
|
|
46
|
+
* hidden extents. A box outside an `overflow:auto` ancestor is a scroll region;
|
|
47
|
+
* the same box outside an `overflow:hidden` ancestor is content nobody can
|
|
48
|
+
* reach. Reporting the extent without the reachability would be a measurement
|
|
49
|
+
* the reader cannot act on.
|
|
50
|
+
*/
|
|
51
|
+
export function clipFactFor(el, byId) {
|
|
52
|
+
if (!el.visible)
|
|
53
|
+
return undefined;
|
|
54
|
+
for (const ancestor of clippingAncestors(el, byId)) {
|
|
55
|
+
const clipsX = CLIPS.test(ancestor.overflowX);
|
|
56
|
+
const clipsY = CLIPS.test(ancestor.overflowY);
|
|
57
|
+
if (!clipsX && !clipsY)
|
|
58
|
+
continue;
|
|
59
|
+
const hidden = hiddenExtents(el, ancestor);
|
|
60
|
+
// An axis that does not clip cannot hide anything on that axis.
|
|
61
|
+
if (!clipsX) {
|
|
62
|
+
hidden.left = 0;
|
|
63
|
+
hidden.right = 0;
|
|
64
|
+
}
|
|
65
|
+
if (!clipsY) {
|
|
66
|
+
hidden.top = 0;
|
|
67
|
+
hidden.bottom = 0;
|
|
68
|
+
}
|
|
69
|
+
if (!hidden.top && !hidden.bottom && !hidden.left && !hidden.right)
|
|
70
|
+
return undefined;
|
|
71
|
+
const visibleWidth = Math.max(0, el.rect.width - hidden.left - hidden.right);
|
|
72
|
+
const visibleHeight = Math.max(0, el.rect.height - hidden.top - hidden.bottom);
|
|
73
|
+
return {
|
|
74
|
+
by: ancestor.id,
|
|
75
|
+
byTag: ancestor.tag,
|
|
76
|
+
overflow: overflowLabel(ancestor),
|
|
77
|
+
hidden,
|
|
78
|
+
visible: { width: visibleWidth, height: visibleHeight },
|
|
79
|
+
// Not the same question as `scrollHeight > clientHeight`: an
|
|
80
|
+
// `overflow:hidden` box has scroll extent and no reachability.
|
|
81
|
+
scrollable: (REACHABLE.test(ancestor.overflowY) && ancestor.scrollHeight - ancestor.clientHeight > 1) ||
|
|
82
|
+
(REACHABLE.test(ancestor.overflowX) && ancestor.scrollWidth - ancestor.clientWidth > 1),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Attach `clip` to every element it applies to. Mutates and returns `elements`. */
|
|
88
|
+
export function attachClipFacts(elements) {
|
|
89
|
+
const byId = new Map(elements.map((el) => [el.id, el]));
|
|
90
|
+
for (const el of elements) {
|
|
91
|
+
const clip = clipFactFor(el, byId);
|
|
92
|
+
if (clip)
|
|
93
|
+
el.clip = clip;
|
|
94
|
+
}
|
|
95
|
+
return elements;
|
|
96
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectSnapshot = collectSnapshot;
|
|
4
|
+
function collectSnapshot() {
|
|
5
|
+
// Inlined, not imported: `page.evaluate` serializes only this function's own
|
|
6
|
+
// source, so nothing from module scope survives the trip into the page.
|
|
7
|
+
// Kept in sync with `limits.ts` by `test/unit/limits.test.ts`.
|
|
8
|
+
const MAX_ELEMENTS = 5000;
|
|
9
|
+
const SKIPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG']);
|
|
10
|
+
const TEXT_PREVIEW_LENGTH = 60;
|
|
11
|
+
const NAME_LENGTH = 80;
|
|
12
|
+
const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa'];
|
|
13
|
+
// Kept in sync with `limits.ts#COLOR_TOKEN_CAP` by `test/unit/limits.test.ts` --
|
|
14
|
+
// duplicated, not imported, for the same reason `MAX_ELEMENTS` is: this
|
|
15
|
+
// function's source is serialized whole into the page, and a runtime import
|
|
16
|
+
// from another module would not survive that trip.
|
|
17
|
+
const COLOR_TOKEN_CAP = 120;
|
|
18
|
+
// Kept in sync with `limits.ts#capBackgroundImage` -- same reason, same
|
|
19
|
+
// discipline. Preserves both ends of a long gradient string rather than a
|
|
20
|
+
// plain prefix cut, so the *last* color stop survives capture; see that
|
|
21
|
+
// function's doc comment (A4).
|
|
22
|
+
function capBackgroundImage(raw) {
|
|
23
|
+
if (raw.length <= COLOR_TOKEN_CAP)
|
|
24
|
+
return raw;
|
|
25
|
+
const ELLIPSIS = '…';
|
|
26
|
+
const half = Math.max(0, Math.floor((COLOR_TOKEN_CAP - ELLIPSIS.length) / 2));
|
|
27
|
+
return `${raw.slice(0, half)}${ELLIPSIS}${raw.slice(raw.length - half)}`;
|
|
28
|
+
}
|
|
29
|
+
// ---- computed role ------------------------------------------------------
|
|
30
|
+
// The prototype captured `getAttribute('role')`, which is null on nearly
|
|
31
|
+
// every real element, so the identity ladder's strongest rung was dead on
|
|
32
|
+
// arrival. This is the implicit-role mapping that fixes it.
|
|
33
|
+
// biome-ignore format: a lookup table reads as a table; one tag per line is 37
|
|
34
|
+
// lines of noise for the same information.
|
|
35
|
+
const IMPLICIT_ROLE = {
|
|
36
|
+
ARTICLE: 'article', ASIDE: 'complementary', BUTTON: 'button', DIALOG: 'dialog',
|
|
37
|
+
FIELDSET: 'group', FIGURE: 'figure', FOOTER: 'contentinfo', FORM: 'form',
|
|
38
|
+
H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading',
|
|
39
|
+
H6: 'heading', HEADER: 'banner', HR: 'separator', IMG: 'img', LI: 'listitem',
|
|
40
|
+
MAIN: 'main', NAV: 'navigation', OL: 'list', OPTION: 'option', OUTPUT: 'status',
|
|
41
|
+
P: 'paragraph', PROGRESS: 'progressbar', SECTION: 'region', SELECT: 'combobox',
|
|
42
|
+
SUMMARY: 'button', TABLE: 'table', TBODY: 'rowgroup', TD: 'cell',
|
|
43
|
+
TEXTAREA: 'textbox', TFOOT: 'rowgroup', TH: 'columnheader', THEAD: 'rowgroup',
|
|
44
|
+
TR: 'row', UL: 'list',
|
|
45
|
+
};
|
|
46
|
+
// biome-ignore format: see above.
|
|
47
|
+
const INPUT_ROLE = {
|
|
48
|
+
button: 'button', checkbox: 'checkbox', email: 'textbox', image: 'button',
|
|
49
|
+
number: 'spinbutton', radio: 'radio', range: 'slider', reset: 'button',
|
|
50
|
+
search: 'searchbox', submit: 'button', tel: 'textbox', text: 'textbox',
|
|
51
|
+
url: 'textbox',
|
|
52
|
+
};
|
|
53
|
+
/** Roles whose accessible name comes from their own content. */
|
|
54
|
+
// biome-ignore format: see above.
|
|
55
|
+
const NAME_FROM_CONTENT = new Set([
|
|
56
|
+
'button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link',
|
|
57
|
+
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row',
|
|
58
|
+
'rowheader', 'switch', 'tab', 'tooltip', 'treeitem',
|
|
59
|
+
]);
|
|
60
|
+
function computedRole(el) {
|
|
61
|
+
const explicit = (el.getAttribute('role') ?? '').trim().split(/\s+/)[0];
|
|
62
|
+
if (explicit)
|
|
63
|
+
return explicit;
|
|
64
|
+
const tag = el.tagName.toUpperCase();
|
|
65
|
+
if (tag === 'A' || tag === 'AREA')
|
|
66
|
+
return el.hasAttribute('href') ? 'link' : null;
|
|
67
|
+
if (tag === 'INPUT') {
|
|
68
|
+
const type = (el.getAttribute('type') ?? 'text').toLowerCase();
|
|
69
|
+
return INPUT_ROLE[type] ?? 'textbox';
|
|
70
|
+
}
|
|
71
|
+
// `header` and `footer` are landmarks only at the top level.
|
|
72
|
+
if ((tag === 'HEADER' || tag === 'FOOTER') && el.closest('article,aside,main,nav,section')) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
// `section` is a region only when it has an accessible name.
|
|
76
|
+
if (tag === 'SECTION' &&
|
|
77
|
+
!el.getAttribute('aria-label') &&
|
|
78
|
+
!el.getAttribute('aria-labelledby')) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return IMPLICIT_ROLE[tag] ?? null;
|
|
82
|
+
}
|
|
83
|
+
function trimName(value) {
|
|
84
|
+
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
|
85
|
+
return text ? text.slice(0, NAME_LENGTH) : null;
|
|
86
|
+
}
|
|
87
|
+
function accessibleName(el, role) {
|
|
88
|
+
const label = trimName(el.getAttribute('aria-label'));
|
|
89
|
+
if (label)
|
|
90
|
+
return label;
|
|
91
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
92
|
+
if (labelledby) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
for (const id of labelledby.split(/\s+/)) {
|
|
95
|
+
parts.push(document.getElementById(id)?.textContent ?? '');
|
|
96
|
+
}
|
|
97
|
+
const joined = trimName(parts.join(' '));
|
|
98
|
+
if (joined)
|
|
99
|
+
return joined;
|
|
100
|
+
}
|
|
101
|
+
const tag = el.tagName.toUpperCase();
|
|
102
|
+
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
|
|
103
|
+
if (el.id) {
|
|
104
|
+
const forLabel = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
105
|
+
const text = trimName(forLabel?.textContent);
|
|
106
|
+
if (text)
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
const wrapping = trimName(el.closest('label')?.textContent);
|
|
110
|
+
if (wrapping)
|
|
111
|
+
return wrapping;
|
|
112
|
+
const placeholder = trimName(el.getAttribute('placeholder'));
|
|
113
|
+
if (placeholder)
|
|
114
|
+
return placeholder;
|
|
115
|
+
}
|
|
116
|
+
if (tag === 'IMG')
|
|
117
|
+
return trimName(el.getAttribute('alt'));
|
|
118
|
+
if (role && NAME_FROM_CONTENT.has(role)) {
|
|
119
|
+
const text = trimName(el.textContent);
|
|
120
|
+
if (text)
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
return trimName(el.getAttribute('title'));
|
|
124
|
+
}
|
|
125
|
+
// ---- geometry / style ---------------------------------------------------
|
|
126
|
+
function childrenOf(el) {
|
|
127
|
+
const result = [];
|
|
128
|
+
for (let i = 0; i < el.children.length; i++) {
|
|
129
|
+
const child = el.children[i];
|
|
130
|
+
if (child)
|
|
131
|
+
result.push(child);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
function boxSides(cs, prefix) {
|
|
136
|
+
const suffix = prefix === 'border' ? 'Width' : '';
|
|
137
|
+
return {
|
|
138
|
+
top: Number.parseFloat(cs.getPropertyValue(`${prefix}-top${suffix}`)) || 0,
|
|
139
|
+
right: Number.parseFloat(cs.getPropertyValue(`${prefix}-right${suffix}`)) || 0,
|
|
140
|
+
bottom: Number.parseFloat(cs.getPropertyValue(`${prefix}-bottom${suffix}`)) || 0,
|
|
141
|
+
left: Number.parseFloat(cs.getPropertyValue(`${prefix}-left${suffix}`)) || 0,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function headingLevel(el) {
|
|
145
|
+
const ariaLevel = el.getAttribute('aria-level');
|
|
146
|
+
if (ariaLevel && Number.isFinite(Number(ariaLevel)))
|
|
147
|
+
return Number(ariaLevel);
|
|
148
|
+
const match = /^H([1-6])$/.exec(el.tagName);
|
|
149
|
+
return match?.[1] ? Number(match[1]) : null;
|
|
150
|
+
}
|
|
151
|
+
function ownText(el) {
|
|
152
|
+
let text = '';
|
|
153
|
+
for (const node of el.childNodes) {
|
|
154
|
+
if (node.nodeType === 3 /* TEXT_NODE */)
|
|
155
|
+
text += node.textContent ?? '';
|
|
156
|
+
}
|
|
157
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, TEXT_PREVIEW_LENGTH);
|
|
158
|
+
}
|
|
159
|
+
const elements = [];
|
|
160
|
+
const byId = new Map();
|
|
161
|
+
// The DOM element behind each captured record, kept only long enough for
|
|
162
|
+
// the second, post-walk pass below to hit-test it -- `visit` is a pre-order
|
|
163
|
+
// walk, so an *unrelated* overlay elsewhere in the document (a modal
|
|
164
|
+
// appended near the end of `<body>`, positioned over content captured
|
|
165
|
+
// earlier) would not have an id yet if this ran inline during the walk.
|
|
166
|
+
const domById = new Map();
|
|
167
|
+
let nextId = 0;
|
|
168
|
+
let truncated = false;
|
|
169
|
+
// `NotMeasured` counters. See the doc comment on the type: this walk never
|
|
170
|
+
// enters a shadow root or an iframe document, so these are the only record
|
|
171
|
+
// of what it walked past.
|
|
172
|
+
let shadowRootsOpen = 0;
|
|
173
|
+
let iframesSameOrigin = 0;
|
|
174
|
+
let iframesUnknownOrigin = 0;
|
|
175
|
+
function visit(el, parentId) {
|
|
176
|
+
if (elements.length >= MAX_ELEMENTS) {
|
|
177
|
+
truncated = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// SVG-namespace elements report a lowercase `tagName`, unlike HTML
|
|
181
|
+
// elements which are always uppercase — so this comparison must normalize
|
|
182
|
+
// case or the skip never fires for SVG internals.
|
|
183
|
+
const tagName = el.tagName.toUpperCase();
|
|
184
|
+
if (SKIPPED_TAGS.has(tagName))
|
|
185
|
+
return;
|
|
186
|
+
// `NotMeasured`: this walk reads `el.children`, which is light-DOM only --
|
|
187
|
+
// it never descends into a shadow root or an iframe document, so this is
|
|
188
|
+
// the only place either is ever seen at all.
|
|
189
|
+
//
|
|
190
|
+
// An *open* shadow root is detectable after the fact: `el.shadowRoot` is
|
|
191
|
+
// the live reference `attachShadow({mode:'open'})` returned. A *closed*
|
|
192
|
+
// one returns `null` from that same property -- indistinguishable from
|
|
193
|
+
// "no shadow root here" -- so closed roots stay uncounted; see the
|
|
194
|
+
// `NotMeasured` doc comment for why the header reports that as 'unknown'
|
|
195
|
+
// rather than guessing zero.
|
|
196
|
+
if (el.shadowRoot)
|
|
197
|
+
shadowRootsOpen++;
|
|
198
|
+
if (tagName === 'IFRAME') {
|
|
199
|
+
// `contentDocument` is `null` and does not throw whenever this document
|
|
200
|
+
// cannot read the frame's document -- but that is not only true
|
|
201
|
+
// cross-origin. It is also `null` for a same-origin frame on `file://`
|
|
202
|
+
// (no origin to compare against), a `sandbox`-ed frame missing
|
|
203
|
+
// `allow-same-origin` (an opaque origin, even when the `src` is the same
|
|
204
|
+
// server), and a frame that has not navigated yet. All four look
|
|
205
|
+
// identical from here, so a `null` reading can only ever mean "this walk
|
|
206
|
+
// cannot resolve the origin", never a positive cross-origin
|
|
207
|
+
// classification -- that is what `unknownOrigin` is for. A non-null
|
|
208
|
+
// `contentDocument` *is* a reliable same-origin signal, so that half
|
|
209
|
+
// stays a real count.
|
|
210
|
+
if (el.contentDocument !== null)
|
|
211
|
+
iframesSameOrigin++;
|
|
212
|
+
else
|
|
213
|
+
iframesUnknownOrigin++;
|
|
214
|
+
}
|
|
215
|
+
const cs = getComputedStyle(el);
|
|
216
|
+
const rect = el.getBoundingClientRect();
|
|
217
|
+
const opacity = Number.parseFloat(cs.getPropertyValue('opacity'));
|
|
218
|
+
const display = cs.getPropertyValue('display');
|
|
219
|
+
const visibility = cs.getPropertyValue('visibility');
|
|
220
|
+
const visible = display !== 'none' &&
|
|
221
|
+
visibility !== 'hidden' &&
|
|
222
|
+
!(Number.isFinite(opacity) && opacity === 0) &&
|
|
223
|
+
rect.width > 0 &&
|
|
224
|
+
rect.height > 0;
|
|
225
|
+
const id = `e${nextId}`;
|
|
226
|
+
nextId++;
|
|
227
|
+
let testId = null;
|
|
228
|
+
for (const attr of TESTID_ATTRS) {
|
|
229
|
+
const value = el.getAttribute(attr);
|
|
230
|
+
if (value) {
|
|
231
|
+
testId = value;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const role = computedRole(el);
|
|
236
|
+
const record = {
|
|
237
|
+
id,
|
|
238
|
+
tag: el.tagName.toLowerCase(),
|
|
239
|
+
testId,
|
|
240
|
+
domId: el.id || null,
|
|
241
|
+
role,
|
|
242
|
+
name: accessibleName(el, role),
|
|
243
|
+
className: el.getAttribute('class') ?? '',
|
|
244
|
+
headingLevel: headingLevel(el),
|
|
245
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
246
|
+
scrollWidth: el.scrollWidth,
|
|
247
|
+
scrollHeight: el.scrollHeight,
|
|
248
|
+
clientWidth: el.clientWidth,
|
|
249
|
+
clientHeight: el.clientHeight,
|
|
250
|
+
overflowX: cs.getPropertyValue('overflow-x'),
|
|
251
|
+
overflowY: cs.getPropertyValue('overflow-y'),
|
|
252
|
+
position: cs.getPropertyValue('position'),
|
|
253
|
+
display,
|
|
254
|
+
visibility,
|
|
255
|
+
opacity: Number.isFinite(opacity) ? opacity : 1,
|
|
256
|
+
margin: boxSides(cs, 'margin'),
|
|
257
|
+
padding: boxSides(cs, 'padding'),
|
|
258
|
+
gap: {
|
|
259
|
+
row: Number.parseFloat(cs.getPropertyValue('row-gap')) || 0,
|
|
260
|
+
column: Number.parseFloat(cs.getPropertyValue('column-gap')) || 0,
|
|
261
|
+
},
|
|
262
|
+
border: boxSides(cs, 'border'),
|
|
263
|
+
color: cs.getPropertyValue('color'),
|
|
264
|
+
backgroundColor: cs.getPropertyValue('background-color'),
|
|
265
|
+
backgroundImage: capBackgroundImage(cs.getPropertyValue('background-image')),
|
|
266
|
+
textShadow: cs.getPropertyValue('text-shadow').slice(0, COLOR_TOKEN_CAP),
|
|
267
|
+
textStrokeWidth: cs.getPropertyValue('-webkit-text-stroke-width'),
|
|
268
|
+
textStrokeColor: cs.getPropertyValue('-webkit-text-stroke-color'),
|
|
269
|
+
fontSize: cs.getPropertyValue('font-size'),
|
|
270
|
+
fontWeight: Number.parseFloat(cs.getPropertyValue('font-weight')) || 400,
|
|
271
|
+
textAlign: cs.getPropertyValue('text-align'),
|
|
272
|
+
flexDirection: cs.getPropertyValue('flex-direction'),
|
|
273
|
+
gridTemplateColumns: cs.getPropertyValue('grid-template-columns'),
|
|
274
|
+
textOverflow: cs.getPropertyValue('text-overflow'),
|
|
275
|
+
whiteSpace: cs.getPropertyValue('white-space'),
|
|
276
|
+
overflowWrap: cs.getPropertyValue('overflow-wrap'),
|
|
277
|
+
text: ownText(el),
|
|
278
|
+
parentId,
|
|
279
|
+
childIds: [],
|
|
280
|
+
visible,
|
|
281
|
+
ariaExpanded: el.getAttribute('aria-expanded'),
|
|
282
|
+
// Filled in by the second pass below, once every id in the document
|
|
283
|
+
// exists to resolve a hit-test against -- see `domById`'s doc comment.
|
|
284
|
+
hitStackIds: null,
|
|
285
|
+
paintOrder: null,
|
|
286
|
+
};
|
|
287
|
+
elements.push(record);
|
|
288
|
+
byId.set(id, record);
|
|
289
|
+
domById.set(id, el);
|
|
290
|
+
if (parentId)
|
|
291
|
+
byId.get(parentId)?.childIds.push(id);
|
|
292
|
+
for (const child of childrenOf(el)) {
|
|
293
|
+
if (elements.length >= MAX_ELEMENTS) {
|
|
294
|
+
truncated = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
visit(child, id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
visit(document.body, null);
|
|
301
|
+
// The overlap signal `snapshot/color.ts` needs to tell "a real ancestor
|
|
302
|
+
// backdrop" from "some unrelated element painted over or behind this
|
|
303
|
+
// text" -- see `ElementRecord.hitStackIds`'s doc comment for why this is a
|
|
304
|
+
// second pass rather than done inline above. Gated on the same "visible,
|
|
305
|
+
// has its own text" condition the color fact itself uses (§4 of
|
|
306
|
+
// `docs/notes/03-color.md`): no text, no ink-on-backdrop question to probe,
|
|
307
|
+
// so no reason to pay for a hit test.
|
|
308
|
+
const reverseId = new Map();
|
|
309
|
+
for (const [id, domEl] of domById)
|
|
310
|
+
reverseId.set(domEl, id);
|
|
311
|
+
for (const [id, domEl] of domById) {
|
|
312
|
+
const record = byId.get(id);
|
|
313
|
+
if (!record?.visible || !record.text)
|
|
314
|
+
continue;
|
|
315
|
+
const rect = domEl.getBoundingClientRect();
|
|
316
|
+
const cx = rect.x + rect.width / 2;
|
|
317
|
+
const cy = rect.y + rect.height / 2;
|
|
318
|
+
const stack = document.elementsFromPoint(cx, cy);
|
|
319
|
+
const ids = [];
|
|
320
|
+
for (const hit of stack) {
|
|
321
|
+
const hitId = reverseId.get(hit);
|
|
322
|
+
// An entry this capture never assigned an id to (an `SVG` internal, a
|
|
323
|
+
// node past the element cap) carries no signal `color.ts` can act on --
|
|
324
|
+
// dropped rather than guessed at, the same discipline `NotMeasured`
|
|
325
|
+
// uses for what a capture walked past without measuring.
|
|
326
|
+
if (hitId)
|
|
327
|
+
ids.push(hitId);
|
|
328
|
+
}
|
|
329
|
+
record.hitStackIds = ids;
|
|
330
|
+
}
|
|
331
|
+
// `<html>` (`documentElement`) is never part of `elements` -- this walk
|
|
332
|
+
// starts at `<body>` (see this function's own `visit(document.body, null)`
|
|
333
|
+
// call below) -- so without reading it here, a compositing walk that runs
|
|
334
|
+
// off the top of the ancestor chain has no way to know the browser's real
|
|
335
|
+
// default canvas fill and no honest option but to guess (A2). See
|
|
336
|
+
// `DocumentBackground`'s doc comment.
|
|
337
|
+
const htmlStyle = getComputedStyle(document.documentElement);
|
|
338
|
+
return {
|
|
339
|
+
elements,
|
|
340
|
+
truncated,
|
|
341
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
342
|
+
notMeasured: {
|
|
343
|
+
shadowRoots: { open: shadowRootsOpen, closed: 'unknown' },
|
|
344
|
+
iframes: {
|
|
345
|
+
sameOrigin: iframesSameOrigin,
|
|
346
|
+
// This walk has no positive signal for cross-origin -- see the check
|
|
347
|
+
// above -- so it never asserts one. An unreachable frame is always
|
|
348
|
+
// counted as `unknownOrigin`, never guessed into `crossOrigin`.
|
|
349
|
+
crossOrigin: 0,
|
|
350
|
+
unknownOrigin: iframesUnknownOrigin,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
documentBackground: {
|
|
354
|
+
backgroundColor: htmlStyle.getPropertyValue('background-color'),
|
|
355
|
+
colorScheme: htmlStyle.getPropertyValue('color-scheme'),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `collectSnapshot` is a function reference, not a call site — the capture path
|
|
3
|
+
* passes it straight to `page.evaluate(collectSnapshot)`. Its body executes
|
|
4
|
+
* inside the page, so it must not close over or import anything that only
|
|
5
|
+
* exists on the Node side; only type-only imports (erased at compile time) are
|
|
6
|
+
* safe, and every constant the walk needs lives inside the function body.
|
|
7
|
+
*
|
|
8
|
+
* This is the **portable** capture path: it works in Chromium, Firefox and
|
|
9
|
+
* WebKit alike. The CDP fast path in `cdp.ts` is ~6x quicker and returns facts
|
|
10
|
+
* this cannot (paint order, real line boxes, blended backgrounds) but is
|
|
11
|
+
* Chromium-only and experimental, so this one is not going away.
|
|
12
|
+
*
|
|
13
|
+
* Ported from the prototype's `snapshot/collect.ts`, with the computed role and
|
|
14
|
+
* accessible name that step 2 proved rung 3 cannot work without. Clipping is
|
|
15
|
+
* deliberately *not* computed here — see `clip.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snapshot } from "./types.cjs";
|
|
18
|
+
export declare function collectSnapshot(): Snapshot;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `collectSnapshot` is a function reference, not a call site — the capture path
|
|
3
|
+
* passes it straight to `page.evaluate(collectSnapshot)`. Its body executes
|
|
4
|
+
* inside the page, so it must not close over or import anything that only
|
|
5
|
+
* exists on the Node side; only type-only imports (erased at compile time) are
|
|
6
|
+
* safe, and every constant the walk needs lives inside the function body.
|
|
7
|
+
*
|
|
8
|
+
* This is the **portable** capture path: it works in Chromium, Firefox and
|
|
9
|
+
* WebKit alike. The CDP fast path in `cdp.ts` is ~6x quicker and returns facts
|
|
10
|
+
* this cannot (paint order, real line boxes, blended backgrounds) but is
|
|
11
|
+
* Chromium-only and experimental, so this one is not going away.
|
|
12
|
+
*
|
|
13
|
+
* Ported from the prototype's `snapshot/collect.ts`, with the computed role and
|
|
14
|
+
* accessible name that step 2 proved rung 3 cannot work without. Clipping is
|
|
15
|
+
* deliberately *not* computed here — see `clip.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snapshot } from "./types.js";
|
|
18
|
+
export declare function collectSnapshot(): Snapshot;
|