@piwitests/reporter 0.44.0 → 0.46.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/dist/cli/index.js +539 -244
- package/dist/global-setup-module.js +138 -0
- package/dist/index.d.ts +116 -2
- package/dist/index.js +3124 -377
- package/dist/internal/capture/attachments.d.ts +1 -0
- package/dist/internal/capture/attachments.js +2 -1
- package/dist/internal/capture/capture-fixtures.d.ts +374 -5
- package/dist/internal/capture/capture-fixtures.js +1136 -113
- package/dist/internal/capture/locator-healing.js +9 -7
- package/dist/internal/capture/pick-on-failure.js +130 -37
- package/package.json +1 -1
- package/templates/skills/apply-locator-healing/SKILL.md +2 -0
- package/templates/skills/investigate-failure/SKILL.md +5 -1
- package/templates/skills/write-the-missing-test/SKILL.md +9 -2
|
@@ -32,6 +32,7 @@ var capture_fixtures_exports = {};
|
|
|
32
32
|
__export(capture_fixtures_exports, {
|
|
33
33
|
CAPTURED_ATTRS_ARG: () => CAPTURED_ATTRS_ARG,
|
|
34
34
|
LocatorPageLog: () => LocatorPageLog,
|
|
35
|
+
activeResourceLedger: () => activeResourceLedger,
|
|
35
36
|
ariaSnapshotBestEffort: () => ariaSnapshotBestEffort,
|
|
36
37
|
ariaSnapshotJSONBestEffort: () => ariaSnapshotJSONBestEffort,
|
|
37
38
|
boxCaptureFrames: () => boxCaptureFrames,
|
|
@@ -44,20 +45,26 @@ __export(capture_fixtures_exports, {
|
|
|
44
45
|
internalCall: () => internalCall,
|
|
45
46
|
noteLocatorCall: () => noteLocatorCall,
|
|
46
47
|
noteNavigation: () => noteNavigation,
|
|
48
|
+
noteResourceUse: () => noteResourceUse,
|
|
47
49
|
pageMapFetcher: () => pageMapFetcher,
|
|
48
50
|
piwiFixtures: () => piwiFixtures,
|
|
49
51
|
probeElementAttrs: () => probeElementAttrs,
|
|
52
|
+
recordResourceCensus: () => recordResourceCensus,
|
|
50
53
|
resolveCodeReach: () => resolveCodeReach,
|
|
51
54
|
startCodeReach: () => startCodeReach,
|
|
52
|
-
|
|
55
|
+
startResourceLedger: () => startResourceLedger,
|
|
56
|
+
stopCodeReach: () => stopCodeReach,
|
|
57
|
+
stopResourceLedger: () => stopResourceLedger
|
|
53
58
|
});
|
|
54
59
|
module.exports = __toCommonJS(capture_fixtures_exports);
|
|
55
|
-
var
|
|
60
|
+
var path12 = __toESM(require("path"));
|
|
56
61
|
var import_node_zlib2 = require("zlib");
|
|
57
62
|
|
|
58
63
|
// ../picker-dom/src/probe.ts
|
|
59
64
|
function probeElementAttrs(el, arg) {
|
|
60
65
|
const { keep, tagRoles, inputRoles, roleSources, includeStructural } = arg;
|
|
66
|
+
const countMatches = arg.countMatches !== false;
|
|
67
|
+
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
61
68
|
const FIELD_TAGS = ["input", "select", "textarea"];
|
|
62
69
|
const textWithout = (root, skip) => {
|
|
63
70
|
if (root === skip) return "";
|
|
@@ -82,42 +89,45 @@ function probeElementAttrs(el, arg) {
|
|
|
82
89
|
}
|
|
83
90
|
};
|
|
84
91
|
const attrMap = {};
|
|
85
|
-
|
|
92
|
+
const keys = arg.testIdAttribute && keep.indexOf(arg.testIdAttribute) === -1 ? [...keep, arg.testIdAttribute] : keep;
|
|
93
|
+
for (const key of keys) {
|
|
86
94
|
const v = el.getAttribute(key) ?? el[key];
|
|
87
95
|
attrMap[key] = typeof v === "string" ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
|
|
88
96
|
}
|
|
89
97
|
const r = el.getBoundingClientRect();
|
|
90
98
|
const selectorCounts = {};
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
if (countMatches) {
|
|
100
|
+
try {
|
|
101
|
+
const doc = el.ownerDocument;
|
|
102
|
+
const cssEsc = (s) => doc.defaultView.CSS.escape(s);
|
|
103
|
+
const count = (sel) => {
|
|
104
|
+
try {
|
|
105
|
+
return doc.querySelectorAll(sel).length;
|
|
106
|
+
} catch {
|
|
107
|
+
return void 0;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (attrMap[testIdAttr]) {
|
|
111
|
+
selectorCounts.testId = count(`[${testIdAttr}=${JSON.stringify(attrMap[testIdAttr])}]`);
|
|
99
112
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
|
|
105
|
-
if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
|
|
106
|
-
if (attrMap["placeholder"]) {
|
|
107
|
-
selectorCounts.placeholder = count(`[placeholder=${JSON.stringify(attrMap["placeholder"])}]`);
|
|
108
|
-
}
|
|
109
|
-
if (attrMap["alt"]) selectorCounts.alt = count(`[alt=${JSON.stringify(attrMap["alt"])}]`);
|
|
110
|
-
if (attrMap["title"]) selectorCounts.title = count(`[title=${JSON.stringify(attrMap["title"])}]`);
|
|
111
|
-
const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
|
|
112
|
-
if (classList.length > 0) {
|
|
113
|
-
const classCounts = {};
|
|
114
|
-
for (const cls of classList) {
|
|
115
|
-
const n = count(`.${cssEsc(cls)}`);
|
|
116
|
-
if (n !== void 0) classCounts[cls] = n;
|
|
113
|
+
if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
|
|
114
|
+
if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
|
|
115
|
+
if (attrMap["placeholder"]) {
|
|
116
|
+
selectorCounts.placeholder = count(`[placeholder=${JSON.stringify(attrMap["placeholder"])}]`);
|
|
117
117
|
}
|
|
118
|
-
selectorCounts.
|
|
118
|
+
if (attrMap["alt"]) selectorCounts.alt = count(`[alt=${JSON.stringify(attrMap["alt"])}]`);
|
|
119
|
+
if (attrMap["title"]) selectorCounts.title = count(`[title=${JSON.stringify(attrMap["title"])}]`);
|
|
120
|
+
const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
|
|
121
|
+
if (classList.length > 0) {
|
|
122
|
+
const classCounts = {};
|
|
123
|
+
for (const cls of classList) {
|
|
124
|
+
const n = count(`.${cssEsc(cls)}`);
|
|
125
|
+
if (n !== void 0) classCounts[cls] = n;
|
|
126
|
+
}
|
|
127
|
+
selectorCounts.classes = classCounts;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
119
130
|
}
|
|
120
|
-
} catch {
|
|
121
131
|
}
|
|
122
132
|
let rolePosition = null;
|
|
123
133
|
const ancestors = [];
|
|
@@ -134,7 +144,7 @@ function probeElementAttrs(el, arg) {
|
|
|
134
144
|
};
|
|
135
145
|
const roleMemo = /* @__PURE__ */ new Map();
|
|
136
146
|
const textMemo = /* @__PURE__ */ new Map();
|
|
137
|
-
const
|
|
147
|
+
const roleOf2 = (n) => {
|
|
138
148
|
const cached = roleMemo.get(n);
|
|
139
149
|
if (cached !== void 0) return cached;
|
|
140
150
|
let role;
|
|
@@ -157,7 +167,7 @@ function probeElementAttrs(el, arg) {
|
|
|
157
167
|
const al = n.getAttribute("aria-level");
|
|
158
168
|
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
159
169
|
};
|
|
160
|
-
const targetRole =
|
|
170
|
+
const targetRole = roleOf2(el);
|
|
161
171
|
const targetLevel = targetRole === "heading" ? levelOf(el) : null;
|
|
162
172
|
const nameOf = (n) => {
|
|
163
173
|
const labelledBy = !!n.getAttribute("aria-labelledby");
|
|
@@ -206,8 +216,8 @@ function probeElementAttrs(el, arg) {
|
|
|
206
216
|
}
|
|
207
217
|
return total;
|
|
208
218
|
};
|
|
209
|
-
const nodes = doc.querySelectorAll(roleSources);
|
|
210
|
-
const nodesUsable = nodes.length <= 4e3;
|
|
219
|
+
const nodes = countMatches ? doc.querySelectorAll(roleSources) : [];
|
|
220
|
+
const nodesUsable = countMatches && nodes.length <= 4e3;
|
|
211
221
|
const rolesUsable = !!targetRole && nodesUsable;
|
|
212
222
|
if (rolesUsable) {
|
|
213
223
|
let roleCountAll = 0;
|
|
@@ -217,7 +227,7 @@ function probeElementAttrs(el, arg) {
|
|
|
217
227
|
let visibleRoleNameCount = 0;
|
|
218
228
|
for (let i = 0; i < nodes.length; i++) {
|
|
219
229
|
const n = nodes[i];
|
|
220
|
-
if (
|
|
230
|
+
if (roleOf2(n) !== targetRole) continue;
|
|
221
231
|
if (n === el) index = roleCountAll;
|
|
222
232
|
roleCountAll++;
|
|
223
233
|
if (targetLevel != null && levelOf(n) === targetLevel) levelCount++;
|
|
@@ -241,17 +251,18 @@ function probeElementAttrs(el, arg) {
|
|
|
241
251
|
};
|
|
242
252
|
}
|
|
243
253
|
}
|
|
244
|
-
if (textNeedle) {
|
|
254
|
+
if (textNeedle && countMatches) {
|
|
245
255
|
const textCount = countTextOwners(doc.body || doc.documentElement, 4e3);
|
|
246
256
|
if (textCount >= 0) selectorCounts.text = textCount;
|
|
247
257
|
}
|
|
248
|
-
if (rolesUsable || textNeedle) {
|
|
258
|
+
if (rolesUsable || textNeedle || !countMatches) {
|
|
249
259
|
const CONTAINER_TAGS = ["form", "nav", "main", "article", "section", "dialog", "table", "li", "tr"];
|
|
250
260
|
const NOISY_DATA_ATTR = /^data-(v-[0-9a-f]+|reactid|react-checksum|svelte-\w+|ng-\w+|ember\w*)$/i;
|
|
251
261
|
const POSITIONAL_DATA_ATTR = /^data-(index|idx|i|key|row|rownum|col|column|position|pos|order|sort|offset|page)$/i;
|
|
252
262
|
const TEST_DATA_ATTRS2 = ["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"];
|
|
263
|
+
const testIdName = testIdAttr.toLowerCase();
|
|
253
264
|
const usableDataAttr = (name, value) => {
|
|
254
|
-
if (!name || name.slice(0, 5) !== "data-" || name ===
|
|
265
|
+
if (!name || name.slice(0, 5) !== "data-" || name.toLowerCase() === testIdName) return false;
|
|
255
266
|
if (NOISY_DATA_ATTR.test(name) || POSITIONAL_DATA_ATTR.test(name)) return false;
|
|
256
267
|
return !!value && value.length <= 120;
|
|
257
268
|
};
|
|
@@ -270,7 +281,7 @@ function probeElementAttrs(el, arg) {
|
|
|
270
281
|
};
|
|
271
282
|
const docRoleCount = (role) => {
|
|
272
283
|
let c = 0;
|
|
273
|
-
for (let i = 0; i < nodes.length; i++) if (
|
|
284
|
+
for (let i = 0; i < nodes.length; i++) if (roleOf2(nodes[i]) === role) c++;
|
|
274
285
|
return c;
|
|
275
286
|
};
|
|
276
287
|
const rawText = (n) => (n.textContent || "").replace(/\s+/g, " ").trim();
|
|
@@ -281,7 +292,13 @@ function probeElementAttrs(el, arg) {
|
|
|
281
292
|
return digits <= letters;
|
|
282
293
|
};
|
|
283
294
|
const usableDiscriminator = (t) => !!t && t.length <= 60 && t !== targetText && namesRatherThanReports(t);
|
|
295
|
+
const hasAtMost = (root, cap) => {
|
|
296
|
+
const walker = doc.createTreeWalker(root, 1);
|
|
297
|
+
for (let n = 0; walker.nextNode(); n++) if (n >= cap) return false;
|
|
298
|
+
return true;
|
|
299
|
+
};
|
|
284
300
|
const discriminatingText = (anc) => {
|
|
301
|
+
if (!countMatches && !hasAtMost(anc, 200)) return null;
|
|
285
302
|
const heading = anc.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]');
|
|
286
303
|
if (heading) {
|
|
287
304
|
const t = rawText(heading);
|
|
@@ -302,7 +319,7 @@ function probeElementAttrs(el, arg) {
|
|
|
302
319
|
const needle = text.toLowerCase();
|
|
303
320
|
let c = 0;
|
|
304
321
|
for (let i = 0; i < nodes.length; i++) {
|
|
305
|
-
if (
|
|
322
|
+
if (roleOf2(nodes[i]) !== role) continue;
|
|
306
323
|
if (normText(nodes[i]).indexOf(needle) !== -1) c++;
|
|
307
324
|
}
|
|
308
325
|
return c;
|
|
@@ -313,7 +330,7 @@ function probeElementAttrs(el, arg) {
|
|
|
313
330
|
depth++;
|
|
314
331
|
const tag = (node.tagName || "").toLowerCase();
|
|
315
332
|
if (tag === "body" || tag === "html") break;
|
|
316
|
-
const testId = node.getAttribute(
|
|
333
|
+
const testId = node.getAttribute(testIdAttr);
|
|
317
334
|
const id = node.getAttribute("id");
|
|
318
335
|
const explicitRole = node.getAttribute("role");
|
|
319
336
|
const ariaLabel = node.getAttribute("aria-label");
|
|
@@ -327,16 +344,16 @@ function probeElementAttrs(el, arg) {
|
|
|
327
344
|
scopedRoleCount = 0;
|
|
328
345
|
for (let i = 0; i < scoped.length; i++) {
|
|
329
346
|
const n = scoped[i];
|
|
330
|
-
if (
|
|
347
|
+
if (roleOf2(n) !== targetRole) continue;
|
|
331
348
|
if (targetLevel != null && levelOf(n) !== targetLevel) continue;
|
|
332
349
|
scopedRoleCount++;
|
|
333
350
|
}
|
|
334
351
|
}
|
|
335
352
|
}
|
|
336
|
-
const scopedTextCount = countTextOwners(node, 2e3);
|
|
353
|
+
const scopedTextCount = countMatches ? countTextOwners(node, 2e3) : -1;
|
|
337
354
|
let filterText = null;
|
|
338
355
|
try {
|
|
339
|
-
if (anchorRole && nodesUsable) filterText = discriminatingText(node);
|
|
356
|
+
if (anchorRole && (nodesUsable || !countMatches)) filterText = discriminatingText(node);
|
|
340
357
|
} catch {
|
|
341
358
|
filterText = null;
|
|
342
359
|
}
|
|
@@ -349,11 +366,13 @@ function probeElementAttrs(el, arg) {
|
|
|
349
366
|
ariaLabel: ariaLabel || null,
|
|
350
367
|
...scopedRoleCount >= 0 ? { scopedRoleCount } : {},
|
|
351
368
|
...scopedTextCount >= 0 ? { scopedTextCount } : {},
|
|
352
|
-
...testId ? { testIdCount: count(`[
|
|
353
|
-
...id ? { idCount: count(`#${cssEsc(id)}`) } : {},
|
|
369
|
+
...testId && countMatches ? { testIdCount: count(`[${testIdAttr}=${JSON.stringify(testId)}]`) } : {},
|
|
370
|
+
...id && countMatches ? { idCount: count(`#${cssEsc(id)}`) } : {},
|
|
354
371
|
...anchorRole && nodesUsable ? { roleCount: docRoleCount(anchorRole) } : {},
|
|
355
|
-
...filterText ? { filterText
|
|
356
|
-
...
|
|
372
|
+
...filterText ? { filterText } : {},
|
|
373
|
+
...filterText && countMatches ? { filterRoleCount: filterMatchCount(anchorRole, filterText) } : {},
|
|
374
|
+
...dataAttr ? { dataAttr } : {},
|
|
375
|
+
...dataAttr && countMatches ? { dataAttrCount: count(`[${dataAttr.name}=${JSON.stringify(dataAttr.value)}]`) } : {}
|
|
357
376
|
});
|
|
358
377
|
}
|
|
359
378
|
node = node.parentElement;
|
|
@@ -391,6 +410,17 @@ function installPickerOverlay(arg) {
|
|
|
391
410
|
return;
|
|
392
411
|
}
|
|
393
412
|
const Z = 2147483600;
|
|
413
|
+
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
414
|
+
const words = {
|
|
415
|
+
banner: "Piwi inspector \u2014 click any element to generate locators for it",
|
|
416
|
+
keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
417
|
+
keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
418
|
+
analyzing: "Analyzing element\u2026"
|
|
419
|
+
};
|
|
420
|
+
for (const key of Object.keys(words)) {
|
|
421
|
+
const given = arg.strings?.[key];
|
|
422
|
+
if (typeof given === "string" && given) words[key] = given;
|
|
423
|
+
}
|
|
394
424
|
const highlight = doc.createElement("div");
|
|
395
425
|
highlight.id = "__piwi_picker_highlight";
|
|
396
426
|
highlight.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};display:none;box-sizing:border-box;border:2px solid #a855f7;background:rgba(168,85,247,.14);border-radius:4px;box-shadow:0 0 0 1px rgba(255,255,255,.9),0 0 0 3px rgba(59,7,100,.55),inset 0 0 0 1px rgba(255,255,255,.5);`;
|
|
@@ -415,27 +445,53 @@ function installPickerOverlay(arg) {
|
|
|
415
445
|
const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
|
|
416
446
|
const hlLocator = (expr) => `<code style="font-family:${MONO}">${hlTokens(expr)}</code>`;
|
|
417
447
|
const head = doc.createElement("div");
|
|
418
|
-
|
|
448
|
+
if (arg.transport === "postMessage") head.textContent = "Click an element to generate locators";
|
|
449
|
+
else if (arg.failing)
|
|
450
|
+
head.innerHTML = `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}`;
|
|
451
|
+
else head.textContent = words.banner;
|
|
419
452
|
const locatorLine = doc.createElement("div");
|
|
420
453
|
locatorLine.id = "__piwi_picker_locator";
|
|
421
454
|
locatorLine.style.cssText = `display:none;margin-top:7px;padding:5px 9px;border-radius:7px;background:#0b1120;border:1px solid #4c1d95;font:13.5px/1.55 ${MONO};word-break:break-word;overflow-wrap:anywhere;`;
|
|
422
455
|
const foot = doc.createElement("div");
|
|
423
456
|
foot.id = "__piwi_picker_foot";
|
|
424
457
|
foot.style.cssText = "color:#9ca3af;margin-top:6px;font-size:12px;";
|
|
425
|
-
foot.textContent =
|
|
458
|
+
foot.textContent = words.keys;
|
|
426
459
|
banner.appendChild(head);
|
|
427
460
|
banner.appendChild(locatorLine);
|
|
428
461
|
banner.appendChild(foot);
|
|
429
462
|
const label = doc.createElement("div");
|
|
430
463
|
label.id = "__piwi_picker_label";
|
|
431
464
|
label.style.cssText = `position:fixed;pointer-events:none;z-index:${Z + 1};display:none;box-sizing:border-box;max-width:min(620px,92vw);background:#0b1120;color:#f9fafb;border:1px solid #7c3aed;border-radius:7px;padding:4px 8px;font:12.5px/1.45 ${MONO};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 4px 18px rgba(0,0,0,.5);`;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
465
|
+
const mountParent = () => {
|
|
466
|
+
let modal = null;
|
|
467
|
+
try {
|
|
468
|
+
modal = doc.querySelector("dialog:modal");
|
|
469
|
+
} catch {
|
|
470
|
+
modal = null;
|
|
471
|
+
}
|
|
472
|
+
if (!modal) return doc.body;
|
|
473
|
+
const s = g.getComputedStyle(modal);
|
|
474
|
+
const holdsFixed = s.transform !== "none" || s.perspective !== "none" || s.filter !== "none" || /\b(paint|layout|strict|content)\b/.test(s.contain) || /\b(transform|perspective|filter)\b/.test(s.willChange);
|
|
475
|
+
return holdsFixed ? doc.body : modal;
|
|
476
|
+
};
|
|
477
|
+
const mount = (...nodes) => {
|
|
478
|
+
const parent = mountParent();
|
|
479
|
+
for (const node of nodes) parent.appendChild(node);
|
|
480
|
+
if (parent !== doc.body) {
|
|
481
|
+
parent.addEventListener(
|
|
482
|
+
"close",
|
|
483
|
+
() => {
|
|
484
|
+
for (const node of nodes) if (node.parentNode === parent) doc.body.appendChild(node);
|
|
485
|
+
},
|
|
486
|
+
{ once: true }
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
mount(highlight, label, banner);
|
|
435
491
|
const escJs = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
436
492
|
const describe = (el) => {
|
|
437
493
|
const tag = (el.tagName || "?").toLowerCase();
|
|
438
|
-
const testId = el.getAttribute && el.getAttribute(
|
|
494
|
+
const testId = el.getAttribute && el.getAttribute(testIdAttr);
|
|
439
495
|
if (testId) return `getByTestId('${escJs(testId)}')`;
|
|
440
496
|
if (el.labels && el.labels.length > 0) {
|
|
441
497
|
const labelText = (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
@@ -470,7 +526,7 @@ function installPickerOverlay(arg) {
|
|
|
470
526
|
const el = chain2[i];
|
|
471
527
|
const tag = (el.tagName || "").toLowerCase();
|
|
472
528
|
if (ACTIONABLE_TAGS.includes(tag)) return i;
|
|
473
|
-
if (el.getAttribute && (el.getAttribute("role") || el.getAttribute(
|
|
529
|
+
if (el.getAttribute && (el.getAttribute("role") || el.getAttribute(testIdAttr))) return i;
|
|
474
530
|
}
|
|
475
531
|
return 0;
|
|
476
532
|
};
|
|
@@ -526,7 +582,7 @@ function installPickerOverlay(arg) {
|
|
|
526
582
|
locatorLine.style.display = "block";
|
|
527
583
|
}
|
|
528
584
|
placeLabel(r);
|
|
529
|
-
foot.textContent =
|
|
585
|
+
foot.textContent = words.keysHovering;
|
|
530
586
|
};
|
|
531
587
|
const redescribe = () => {
|
|
532
588
|
labeled = null;
|
|
@@ -589,6 +645,13 @@ function installPickerOverlay(arg) {
|
|
|
589
645
|
banner.remove();
|
|
590
646
|
if (g.__piwiRedescribe === redescribe) delete g.__piwiRedescribe;
|
|
591
647
|
};
|
|
648
|
+
const teardown = () => {
|
|
649
|
+
removePickingListeners();
|
|
650
|
+
removeSuppressed();
|
|
651
|
+
doc.removeEventListener("click", stop, true);
|
|
652
|
+
doc.removeEventListener("keydown", stop, true);
|
|
653
|
+
cleanup();
|
|
654
|
+
};
|
|
592
655
|
const reportPicked = (el) => {
|
|
593
656
|
removePickingListeners();
|
|
594
657
|
highlight.style.display = "none";
|
|
@@ -599,13 +662,13 @@ function installPickerOverlay(arg) {
|
|
|
599
662
|
g.__piwiSnapshotExtras?.onPick?.();
|
|
600
663
|
doc.addEventListener("click", stop, true);
|
|
601
664
|
doc.addEventListener("keydown", stop, true);
|
|
602
|
-
foot.textContent =
|
|
665
|
+
foot.textContent = words.analyzing;
|
|
603
666
|
g.parent.postMessage({ type: "elementPicked", attrs }, "*");
|
|
604
667
|
} else {
|
|
605
668
|
removeSuppressed();
|
|
606
669
|
g.__piwiPickedElement = el;
|
|
607
670
|
g.__piwiPickState = "picked";
|
|
608
|
-
foot.textContent =
|
|
671
|
+
foot.textContent = words.analyzing;
|
|
609
672
|
}
|
|
610
673
|
};
|
|
611
674
|
const reportSkipped = () => {
|
|
@@ -653,7 +716,7 @@ function installPickerOverlay(arg) {
|
|
|
653
716
|
}
|
|
654
717
|
});
|
|
655
718
|
};
|
|
656
|
-
g.__piwiPickCleanup =
|
|
719
|
+
g.__piwiPickCleanup = teardown;
|
|
657
720
|
doc.addEventListener("mousemove", onMove, true);
|
|
658
721
|
doc.addEventListener("click", onClick, true);
|
|
659
722
|
doc.addEventListener("keydown", onKey, true);
|
|
@@ -675,7 +738,27 @@ function showAnchorPicker(arg) {
|
|
|
675
738
|
}
|
|
676
739
|
const Z = 2147483600;
|
|
677
740
|
const { tagRoles, inputRoles, roleSources, leafRole, leafLevel, leafTestId } = arg;
|
|
678
|
-
const
|
|
741
|
+
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
742
|
+
const words = {
|
|
743
|
+
title: "Scope to stable parents (optional)",
|
|
744
|
+
hint: "Pick one or more parents to anchor the locator to. Hover a row to see the parent.",
|
|
745
|
+
noneSelected: "No parents selected \u2014 standard alternatives only.",
|
|
746
|
+
matchesOne: "\u2713 Selection matches exactly 1 element",
|
|
747
|
+
matchesMany: "\u2717 Selection matches {count} elements",
|
|
748
|
+
countUnavailable: "Match count unavailable",
|
|
749
|
+
containsOne: "contains exactly 1 matching element",
|
|
750
|
+
containsMany: "contains {count} matching elements",
|
|
751
|
+
needsTestId: `add a ${testIdAttr} to make this usable`,
|
|
752
|
+
noHook: "no stable hook",
|
|
753
|
+
use: "Use selected parents",
|
|
754
|
+
skip: "Skip (Esc)"
|
|
755
|
+
};
|
|
756
|
+
for (const key of Object.keys(words)) {
|
|
757
|
+
const given = arg.strings?.[key];
|
|
758
|
+
if (typeof given === "string" && given) words[key] = given;
|
|
759
|
+
}
|
|
760
|
+
const counted = (template, n) => template.replace("{count}", String(n));
|
|
761
|
+
const roleOf2 = (n) => {
|
|
679
762
|
const explicit = n.getAttribute && n.getAttribute("role");
|
|
680
763
|
if (explicit) return explicit;
|
|
681
764
|
const tag = (n.tagName || "").toLowerCase();
|
|
@@ -690,15 +773,17 @@ function showAnchorPicker(arg) {
|
|
|
690
773
|
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
691
774
|
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
692
775
|
};
|
|
776
|
+
const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
|
|
693
777
|
const leafMatches = (scope) => {
|
|
694
778
|
try {
|
|
695
|
-
if (leafTestId) return scope.querySelectorAll(`[
|
|
779
|
+
if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
|
|
780
|
+
if (!leafCountable) return -1;
|
|
696
781
|
const nodes = scope.querySelectorAll(roleSources);
|
|
697
782
|
if (nodes.length > 2e3) return -1;
|
|
698
783
|
let matched = 0;
|
|
699
784
|
for (let i = 0; i < nodes.length; i++) {
|
|
700
785
|
const n = nodes[i];
|
|
701
|
-
if (
|
|
786
|
+
if (roleOf2(n) !== leafRole) continue;
|
|
702
787
|
if (leafLevel != null && levelOf(n) !== leafLevel) continue;
|
|
703
788
|
matched++;
|
|
704
789
|
}
|
|
@@ -728,12 +813,12 @@ function showAnchorPicker(arg) {
|
|
|
728
813
|
depth++;
|
|
729
814
|
const tag = (node.tagName || "").toLowerCase();
|
|
730
815
|
if (tag === "body" || tag === "html") break;
|
|
731
|
-
const testId = node.getAttribute(
|
|
816
|
+
const testId = node.getAttribute(testIdAttr);
|
|
732
817
|
const id = node.getAttribute("id");
|
|
733
818
|
const ariaLabel = node.getAttribute("aria-label");
|
|
734
|
-
const role =
|
|
819
|
+
const role = roleOf2(node);
|
|
735
820
|
const info = { tag, depth, testId: testId || null, id: id || null, ariaLabel: ariaLabel || null, role };
|
|
736
|
-
if (testId) info.testIdCount = count(`[
|
|
821
|
+
if (testId) info.testIdCount = count(`[${testIdAttr}=${JSON.stringify(testId)}]`);
|
|
737
822
|
if (id) {
|
|
738
823
|
try {
|
|
739
824
|
info.idCount = count(`#${doc.defaultView.CSS.escape(id)}`);
|
|
@@ -744,7 +829,7 @@ function showAnchorPicker(arg) {
|
|
|
744
829
|
let roleCount = 0;
|
|
745
830
|
let labeledCount = 0;
|
|
746
831
|
for (const n of roleNodes) {
|
|
747
|
-
if (
|
|
832
|
+
if (roleOf2(n) !== role) continue;
|
|
748
833
|
roleCount++;
|
|
749
834
|
if (ariaLabel && n.getAttribute && n.getAttribute("aria-label") === ariaLabel) labeledCount++;
|
|
750
835
|
}
|
|
@@ -752,7 +837,7 @@ function showAnchorPicker(arg) {
|
|
|
752
837
|
if (ariaLabel) info.labeledRoleCount = labeledCount;
|
|
753
838
|
}
|
|
754
839
|
info.scopedLeafCount = leafMatches(node);
|
|
755
|
-
const hookLabel = testId ?
|
|
840
|
+
const hookLabel = testId ? `${testIdAttr}="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : words.noHook;
|
|
756
841
|
rows.push({
|
|
757
842
|
node,
|
|
758
843
|
info,
|
|
@@ -774,13 +859,13 @@ function showAnchorPicker(arg) {
|
|
|
774
859
|
const pr = el.getBoundingClientRect();
|
|
775
860
|
pickedOutline.style.cssText = `position:fixed;pointer-events:none;z-index:${Z};box-sizing:border-box;border:2px solid #a855f7;background:rgba(168,85,247,.14);border-radius:4px;box-shadow:0 0 0 1px rgba(255,255,255,.9),0 0 0 3px rgba(59,7,100,.55);left:${pr.left}px;top:${pr.top}px;width:${pr.width}px;height:${pr.height}px;`;
|
|
776
861
|
const panel = doc.createElement("div");
|
|
777
|
-
panel.style.cssText = `position:fixed;top:12px;right:12px;z-index:${Z + 3};width:340px;max-height:82vh;overflow:auto;background:#111827;color:#f9fafb;border-radius:10px;padding:16px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 8px 40px rgba(0,0,0,.5);`;
|
|
862
|
+
panel.style.cssText = `position:fixed;top:12px;right:12px;z-index:${Z + 3};width:min(340px,calc(100vw - 56px));max-height:82vh;overflow:auto;background:#111827;color:#f9fafb;border-radius:10px;padding:16px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 8px 40px rgba(0,0,0,.5);`;
|
|
778
863
|
const title = doc.createElement("div");
|
|
779
864
|
title.style.cssText = "font-weight:600;font-size:13px;margin-bottom:2px;";
|
|
780
|
-
title.textContent =
|
|
865
|
+
title.textContent = words.title;
|
|
781
866
|
const sub = doc.createElement("div");
|
|
782
867
|
sub.style.cssText = "color:#9ca3af;margin-bottom:10px;";
|
|
783
|
-
sub.textContent =
|
|
868
|
+
sub.textContent = words.hint;
|
|
784
869
|
panel.appendChild(title);
|
|
785
870
|
panel.appendChild(sub);
|
|
786
871
|
const selected = /* @__PURE__ */ new Set();
|
|
@@ -788,12 +873,12 @@ function showAnchorPicker(arg) {
|
|
|
788
873
|
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
789
874
|
const segMatches = (scope, info) => {
|
|
790
875
|
try {
|
|
791
|
-
if (info.testId) return Array.from(scope.querySelectorAll(`[
|
|
876
|
+
if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
|
|
792
877
|
if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
|
|
793
878
|
const nodes = Array.from(scope.querySelectorAll(roleSources));
|
|
794
879
|
if (nodes.length > 2e3) return [];
|
|
795
880
|
return nodes.filter(
|
|
796
|
-
(n) =>
|
|
881
|
+
(n) => roleOf2(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
|
797
882
|
);
|
|
798
883
|
} catch {
|
|
799
884
|
return [];
|
|
@@ -812,14 +897,15 @@ function showAnchorPicker(arg) {
|
|
|
812
897
|
let total = 0;
|
|
813
898
|
for (const s of scopes) {
|
|
814
899
|
const c = leafMatches(s);
|
|
815
|
-
if (c
|
|
900
|
+
if (c < 0) return -1;
|
|
901
|
+
total += c;
|
|
816
902
|
if (total > 50) return total;
|
|
817
903
|
}
|
|
818
904
|
return total;
|
|
819
905
|
};
|
|
820
906
|
const refreshFooter = () => {
|
|
821
907
|
if (selected.size === 0) {
|
|
822
|
-
footer.textContent =
|
|
908
|
+
footer.textContent = words.noneSelected;
|
|
823
909
|
footer.style.color = "#9ca3af";
|
|
824
910
|
g.__piwiPickChainCount = void 0;
|
|
825
911
|
return;
|
|
@@ -827,10 +913,10 @@ function showAnchorPicker(arg) {
|
|
|
827
913
|
const c = chainCount();
|
|
828
914
|
g.__piwiPickChainCount = c;
|
|
829
915
|
if (c === 1) {
|
|
830
|
-
footer.textContent =
|
|
916
|
+
footer.textContent = words.matchesOne;
|
|
831
917
|
footer.style.color = "#4ade80";
|
|
832
918
|
} else {
|
|
833
|
-
footer.textContent = c < 0 ?
|
|
919
|
+
footer.textContent = c < 0 ? words.countUnavailable : counted(words.matchesMany, c);
|
|
834
920
|
footer.style.color = "#fbbf24";
|
|
835
921
|
}
|
|
836
922
|
};
|
|
@@ -847,7 +933,8 @@ function showAnchorPicker(arg) {
|
|
|
847
933
|
code.textContent = `<${row.info.tag}> ${row.hookLabel}`;
|
|
848
934
|
const hint = doc.createElement("span");
|
|
849
935
|
hint.style.cssText = "color:#9ca3af;";
|
|
850
|
-
|
|
936
|
+
const inside = row.info.scopedLeafCount ?? -1;
|
|
937
|
+
hint.textContent = row.selectable ? inside === 1 ? words.containsOne : inside < 0 ? words.countUnavailable : counted(words.containsMany, inside) : words.needsTestId;
|
|
851
938
|
text.appendChild(code);
|
|
852
939
|
text.appendChild(hint);
|
|
853
940
|
line.appendChild(box);
|
|
@@ -885,6 +972,7 @@ function showAnchorPicker(arg) {
|
|
|
885
972
|
outline.remove();
|
|
886
973
|
outlineLabel.remove();
|
|
887
974
|
pickedOutline.remove();
|
|
975
|
+
if (g.__piwiAnchorCleanup === cleanup) delete g.__piwiAnchorCleanup;
|
|
888
976
|
};
|
|
889
977
|
const done = (state) => {
|
|
890
978
|
g.__piwiPickAnchors = state === "done" ? rows.filter((_, i) => selected.has(i)).map((r) => r.info) : [];
|
|
@@ -901,7 +989,7 @@ function showAnchorPicker(arg) {
|
|
|
901
989
|
buttonRow.style.cssText = "display:flex;gap:8px;margin-top:4px;";
|
|
902
990
|
const useBtn = doc.createElement("button");
|
|
903
991
|
useBtn.style.cssText = "flex:1;background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px;cursor:pointer;font:600 12px system-ui;";
|
|
904
|
-
useBtn.textContent =
|
|
992
|
+
useBtn.textContent = words.use;
|
|
905
993
|
useBtn.addEventListener("click", (e) => {
|
|
906
994
|
e.preventDefault();
|
|
907
995
|
e.stopImmediatePropagation();
|
|
@@ -909,7 +997,7 @@ function showAnchorPicker(arg) {
|
|
|
909
997
|
});
|
|
910
998
|
const skipBtn = doc.createElement("button");
|
|
911
999
|
skipBtn.style.cssText = "background:none;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:8px 10px;cursor:pointer;font:12px system-ui;";
|
|
912
|
-
skipBtn.textContent =
|
|
1000
|
+
skipBtn.textContent = words.skip;
|
|
913
1001
|
skipBtn.addEventListener("click", (e) => {
|
|
914
1002
|
e.preventDefault();
|
|
915
1003
|
e.stopImmediatePropagation();
|
|
@@ -921,10 +1009,32 @@ function showAnchorPicker(arg) {
|
|
|
921
1009
|
refreshFooter();
|
|
922
1010
|
g.__piwiAnchorCleanup = cleanup;
|
|
923
1011
|
doc.addEventListener("keydown", onKey, true);
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1012
|
+
const mountParent = () => {
|
|
1013
|
+
let modal = null;
|
|
1014
|
+
try {
|
|
1015
|
+
modal = doc.querySelector("dialog:modal");
|
|
1016
|
+
} catch {
|
|
1017
|
+
modal = null;
|
|
1018
|
+
}
|
|
1019
|
+
if (!modal) return doc.body;
|
|
1020
|
+
const s = g.getComputedStyle(modal);
|
|
1021
|
+
const holdsFixed = s.transform !== "none" || s.perspective !== "none" || s.filter !== "none" || /\b(paint|layout|strict|content)\b/.test(s.contain) || /\b(transform|perspective|filter)\b/.test(s.willChange);
|
|
1022
|
+
return holdsFixed ? doc.body : modal;
|
|
1023
|
+
};
|
|
1024
|
+
const mount = (...nodes) => {
|
|
1025
|
+
const parent = mountParent();
|
|
1026
|
+
for (const node2 of nodes) parent.appendChild(node2);
|
|
1027
|
+
if (parent !== doc.body) {
|
|
1028
|
+
parent.addEventListener(
|
|
1029
|
+
"close",
|
|
1030
|
+
() => {
|
|
1031
|
+
for (const node2 of nodes) if (node2.parentNode === parent) doc.body.appendChild(node2);
|
|
1032
|
+
},
|
|
1033
|
+
{ once: true }
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
mount(pickedOutline, outline, outlineLabel, panel);
|
|
928
1038
|
}
|
|
929
1039
|
|
|
930
1040
|
// ../picker-dom/src/overlay-confirm.ts
|
|
@@ -1140,7 +1250,7 @@ function isAutoGenerated(value) {
|
|
|
1140
1250
|
return false;
|
|
1141
1251
|
}
|
|
1142
1252
|
var TEST_DATA_ATTRS = /* @__PURE__ */ new Set(["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"]);
|
|
1143
|
-
function generateAlternatives(attrs) {
|
|
1253
|
+
function generateAlternatives(attrs, options = {}) {
|
|
1144
1254
|
const alts = [];
|
|
1145
1255
|
const seen = /* @__PURE__ */ new Set();
|
|
1146
1256
|
const add = (loc) => {
|
|
@@ -1151,12 +1261,14 @@ function generateAlternatives(attrs) {
|
|
|
1151
1261
|
};
|
|
1152
1262
|
const { accessibleName } = attrs;
|
|
1153
1263
|
const tag = attrs.tagName;
|
|
1154
|
-
const role = resolveAriaRole(attrs);
|
|
1264
|
+
const role = options.role !== void 0 ? options.role : resolveAriaRole(attrs);
|
|
1155
1265
|
const counts = attrs.selectorCounts;
|
|
1156
1266
|
const isUnique = (n) => n == null || n <= 1;
|
|
1157
1267
|
const ambiguityPenalty = (n) => n != null && n > 1 ? 45 : 0;
|
|
1158
1268
|
const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
|
|
1159
|
-
const
|
|
1269
|
+
const testIdAttribute = options.testIdAttribute || "data-testid";
|
|
1270
|
+
const selectable = (name2) => name2.toLowerCase() !== testIdAttribute.toLowerCase();
|
|
1271
|
+
const testId = attr(attrs, testIdAttribute);
|
|
1160
1272
|
if (testId && isUnique(counts?.testId)) {
|
|
1161
1273
|
add({
|
|
1162
1274
|
locator: `getByTestId('${esc(testId)}')`,
|
|
@@ -1215,7 +1327,7 @@ function generateAlternatives(attrs) {
|
|
|
1215
1327
|
});
|
|
1216
1328
|
}
|
|
1217
1329
|
const id = attr(attrs, "id");
|
|
1218
|
-
if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
|
|
1330
|
+
if (id && selectable("id") && !isAutoGenerated(id) && isUnique(counts?.id)) {
|
|
1219
1331
|
const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
|
|
1220
1332
|
add({
|
|
1221
1333
|
locator: `locator('${esc(selector)}')`,
|
|
@@ -1225,7 +1337,7 @@ function generateAlternatives(attrs) {
|
|
|
1225
1337
|
});
|
|
1226
1338
|
}
|
|
1227
1339
|
const name = attr(attrs, "name");
|
|
1228
|
-
if (name && isUnique(counts?.name)) {
|
|
1340
|
+
if (name && selectable("name") && isUnique(counts?.name)) {
|
|
1229
1341
|
const selector = `[name="${escCssAttrValue(name)}"]`;
|
|
1230
1342
|
add({
|
|
1231
1343
|
locator: `locator('${esc(selector)}')`,
|
|
@@ -1270,7 +1382,7 @@ function generateAlternatives(attrs) {
|
|
|
1270
1382
|
score: scores.testId
|
|
1271
1383
|
});
|
|
1272
1384
|
}
|
|
1273
|
-
if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
|
|
1385
|
+
if (!idAnchorDone && anc.id && selectable("id") && !isAutoGenerated(anc.id) && anc.idCount === 1) {
|
|
1274
1386
|
idAnchorDone = true;
|
|
1275
1387
|
const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
|
|
1276
1388
|
add({
|
|
@@ -1280,7 +1392,7 @@ function generateAlternatives(attrs) {
|
|
|
1280
1392
|
score: scores.id
|
|
1281
1393
|
});
|
|
1282
1394
|
}
|
|
1283
|
-
if (!dataAnchorDone && anc.dataAttr && anc.dataAttrCount === 1) {
|
|
1395
|
+
if (!dataAnchorDone && anc.dataAttr && selectable(anc.dataAttr.name) && anc.dataAttrCount === 1) {
|
|
1284
1396
|
dataAnchorDone = true;
|
|
1285
1397
|
const { name: name2, value } = anc.dataAttr;
|
|
1286
1398
|
const anchorSelector = `[${name2}="${escCssAttrValue(value)}"]`;
|
|
@@ -1798,7 +1910,8 @@ var ATTACHMENT_NAMES = {
|
|
|
1798
1910
|
pageState: "piwi-page-state",
|
|
1799
1911
|
userPick: "piwi-user-pick",
|
|
1800
1912
|
aiUsage: "piwi-ai-usage",
|
|
1801
|
-
aiMeta: "piwi-ai-meta"
|
|
1913
|
+
aiMeta: "piwi-ai-meta",
|
|
1914
|
+
resources: "piwi-resources"
|
|
1802
1915
|
};
|
|
1803
1916
|
var INTERNAL_ATTACHMENT_NAMES = new Set(Object.values(ATTACHMENT_NAMES));
|
|
1804
1917
|
var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
|
|
@@ -1811,7 +1924,7 @@ function collectPageInventoryInPage(maxEntries) {
|
|
|
1811
1924
|
const doc = g.document;
|
|
1812
1925
|
if (!doc || !g.location) return null;
|
|
1813
1926
|
const clean = (s) => (s || "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
1814
|
-
const
|
|
1927
|
+
const roleOf2 = (el) => {
|
|
1815
1928
|
const explicit = el.getAttribute("role");
|
|
1816
1929
|
if (explicit) return explicit.toLowerCase();
|
|
1817
1930
|
const tag = (el.tagName || "").toLowerCase();
|
|
@@ -1865,7 +1978,7 @@ function collectPageInventoryInPage(maxEntries) {
|
|
|
1865
1978
|
return text;
|
|
1866
1979
|
};
|
|
1867
1980
|
const nameOf = (el) => {
|
|
1868
|
-
const role =
|
|
1981
|
+
const role = roleOf2(el);
|
|
1869
1982
|
const chain = [el.getAttribute("aria-label"), labelledByText(el), labelText(el)];
|
|
1870
1983
|
if (!holdsValue(el, role)) chain.push(el.textContent);
|
|
1871
1984
|
chain.push(el.getAttribute("placeholder"), el.getAttribute("title"), el.getAttribute("alt"));
|
|
@@ -1883,7 +1996,7 @@ function collectPageInventoryInPage(maxEntries) {
|
|
|
1883
1996
|
const controlSelector = 'button, [role="button"], input, select, textarea, [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="switch"], [role="slider"], [role="searchbox"], [role="spinbutton"], [role="textbox"], [role="listbox"]';
|
|
1884
1997
|
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
|
|
1885
1998
|
if (budget <= 0) break;
|
|
1886
|
-
const role =
|
|
1999
|
+
const role = roleOf2(el);
|
|
1887
2000
|
const name = nameOf(el);
|
|
1888
2001
|
if (!name) continue;
|
|
1889
2002
|
const key = `${role}\0${name}`;
|
|
@@ -2038,9 +2151,12 @@ var DEFAULTS = {
|
|
|
2038
2151
|
// Off by default: JavaScript coverage slows the page, so a scheduled job opts in.
|
|
2039
2152
|
captureCodeReach: false,
|
|
2040
2153
|
captureServerTraces: true,
|
|
2154
|
+
captureResources: true,
|
|
2155
|
+
leakCheck: "report",
|
|
2041
2156
|
sampleAriaOnPass: true,
|
|
2042
2157
|
uploadManifest: true,
|
|
2043
2158
|
defaultCapture: true,
|
|
2159
|
+
checkBaseUrl: false,
|
|
2044
2160
|
streaming: true,
|
|
2045
2161
|
streamingBatchSize: 5,
|
|
2046
2162
|
streamingBatchDelay: 2e3,
|
|
@@ -2078,9 +2194,12 @@ var PIWI_ENV_KEYS = {
|
|
|
2078
2194
|
captureCodeReach: "PIWI_CAPTURE_CODE_REACH",
|
|
2079
2195
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
2080
2196
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
2197
|
+
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
2198
|
+
leakCheck: "PIWI_LEAK_CHECK",
|
|
2081
2199
|
sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
|
|
2082
2200
|
uploadManifest: "PIWI_UPLOAD_MANIFEST",
|
|
2083
2201
|
defaultCapture: "PIWI_DEFAULT_CAPTURE",
|
|
2202
|
+
checkBaseUrl: "PIWI_CHECK_BASE_URL",
|
|
2084
2203
|
inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
|
|
2085
2204
|
pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
|
|
2086
2205
|
outputFile: "PIWI_OUTPUT_FILE",
|
|
@@ -2103,6 +2222,7 @@ var PIWI_FLAKE_ENV = {
|
|
|
2103
2222
|
plan: "PIWI_FLAKE_PLAN",
|
|
2104
2223
|
results: "PIWI_FLAKE_RESULTS"
|
|
2105
2224
|
};
|
|
2225
|
+
var PIWI_RESOURCES_RESULTS_ENV = "PIWI_RESOURCES_RESULTS";
|
|
2106
2226
|
var ENV_FALLBACK_SPECS = [
|
|
2107
2227
|
{ option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
|
|
2108
2228
|
{ option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
|
|
@@ -2127,9 +2247,12 @@ var ENV_FALLBACK_SPECS = [
|
|
|
2127
2247
|
{ option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
|
|
2128
2248
|
{ option: "captureCodeReach", env: PIWI_ENV_KEYS.captureCodeReach, kind: "bool" },
|
|
2129
2249
|
{ option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
|
|
2250
|
+
{ option: "captureResources", env: PIWI_ENV_KEYS.captureResources, kind: "bool" },
|
|
2251
|
+
{ option: "leakCheck", env: PIWI_ENV_KEYS.leakCheck, kind: "string" },
|
|
2130
2252
|
{ option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
|
|
2131
2253
|
{ option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
|
|
2132
2254
|
{ option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
|
|
2255
|
+
{ option: "checkBaseUrl", env: PIWI_ENV_KEYS.checkBaseUrl, kind: "bool" },
|
|
2133
2256
|
{ option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
|
|
2134
2257
|
{ option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
|
|
2135
2258
|
{ option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
|
|
@@ -2142,8 +2265,8 @@ function parseRouteKey(routeKey2) {
|
|
|
2142
2265
|
return { method: routeKey2.slice(0, space).toUpperCase(), pattern: routeKey2.slice(space + 1) };
|
|
2143
2266
|
}
|
|
2144
2267
|
function patternToRegex(pattern) {
|
|
2145
|
-
const
|
|
2146
|
-
const escaped =
|
|
2268
|
+
const path13 = pattern.split("?")[0] ?? pattern;
|
|
2269
|
+
const escaped = path13.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:[A-Za-z0-9_]+/g, "[^/]+");
|
|
2147
2270
|
return new RegExp(`^${escaped}$`);
|
|
2148
2271
|
}
|
|
2149
2272
|
function suitePathEqual(a, b) {
|
|
@@ -2166,13 +2289,13 @@ function matchProbeItem(plan, test) {
|
|
|
2166
2289
|
function requestMatchesRoute(method, url, routeKey2) {
|
|
2167
2290
|
const { method: rm, pattern } = parseRouteKey(routeKey2);
|
|
2168
2291
|
if (rm && rm !== method.toUpperCase()) return false;
|
|
2169
|
-
let
|
|
2292
|
+
let path13;
|
|
2170
2293
|
try {
|
|
2171
|
-
|
|
2294
|
+
path13 = new URL(url).pathname;
|
|
2172
2295
|
} catch {
|
|
2173
|
-
|
|
2296
|
+
path13 = url.split("?")[0] ?? url;
|
|
2174
2297
|
}
|
|
2175
|
-
return patternToRegex(pattern).test(
|
|
2298
|
+
return patternToRegex(pattern).test(path13);
|
|
2176
2299
|
}
|
|
2177
2300
|
function createProbeState() {
|
|
2178
2301
|
return { navigated: false, matches: 0 };
|
|
@@ -2194,10 +2317,10 @@ function isProbeMode() {
|
|
|
2194
2317
|
function loadPlan() {
|
|
2195
2318
|
if (planCache !== void 0) return planCache;
|
|
2196
2319
|
planCache = null;
|
|
2197
|
-
const
|
|
2198
|
-
if (
|
|
2320
|
+
const path13 = process.env[PIWI_PROBE_ENV.plan];
|
|
2321
|
+
if (path13) {
|
|
2199
2322
|
try {
|
|
2200
|
-
planCache = JSON.parse(fs2.readFileSync(
|
|
2323
|
+
planCache = JSON.parse(fs2.readFileSync(path13, "utf8"));
|
|
2201
2324
|
} catch {
|
|
2202
2325
|
planCache = null;
|
|
2203
2326
|
}
|
|
@@ -2219,10 +2342,10 @@ function outcomeFromStatus(status, applied) {
|
|
|
2219
2342
|
return status === "failed" || status === "timedOut" || status === "interrupted" ? "noticed" : "not-noticed";
|
|
2220
2343
|
}
|
|
2221
2344
|
function recordProbeOutcome(line) {
|
|
2222
|
-
const
|
|
2223
|
-
if (!
|
|
2345
|
+
const path13 = process.env[PIWI_PROBE_ENV.results];
|
|
2346
|
+
if (!path13) return;
|
|
2224
2347
|
try {
|
|
2225
|
-
fs2.appendFileSync(
|
|
2348
|
+
fs2.appendFileSync(path13, `${JSON.stringify(line)}
|
|
2226
2349
|
`);
|
|
2227
2350
|
} catch {
|
|
2228
2351
|
}
|
|
@@ -3496,12 +3619,23 @@ var BOXED_PREFIX_EXPORTS = [
|
|
|
3496
3619
|
["playwright-core/lib/utils", (mod) => mod?.setBoxedStackPrefixes]
|
|
3497
3620
|
];
|
|
3498
3621
|
var boxing;
|
|
3622
|
+
var resolvedPlaywrightPackageJson;
|
|
3623
|
+
function resolvePlaywrightPackageJson() {
|
|
3624
|
+
if (resolvedPlaywrightPackageJson !== void 0) return resolvedPlaywrightPackageJson;
|
|
3625
|
+
try {
|
|
3626
|
+
const testPackageJson = (0, import_node_module.createRequire)(__filename).resolve("@playwright/test/package.json");
|
|
3627
|
+
resolvedPlaywrightPackageJson = (0, import_node_module.createRequire)(testPackageJson).resolve("playwright/package.json");
|
|
3628
|
+
} catch {
|
|
3629
|
+
resolvedPlaywrightPackageJson = null;
|
|
3630
|
+
}
|
|
3631
|
+
return resolvedPlaywrightPackageJson;
|
|
3632
|
+
}
|
|
3499
3633
|
function resolveBoxing() {
|
|
3500
3634
|
try {
|
|
3501
3635
|
const root = findOwnPackageJson(__dirname)?.root;
|
|
3502
3636
|
if (!root) return null;
|
|
3503
|
-
const
|
|
3504
|
-
|
|
3637
|
+
const playwrightPackageJson = resolvePlaywrightPackageJson();
|
|
3638
|
+
if (!playwrightPackageJson) return null;
|
|
3505
3639
|
const requireFromPlaywright = (0, import_node_module.createRequire)(playwrightPackageJson);
|
|
3506
3640
|
for (const [moduleId, pick] of BOXED_PREFIX_EXPORTS) {
|
|
3507
3641
|
let set;
|
|
@@ -3823,9 +3957,9 @@ function resolveSourcePath(source, roots) {
|
|
|
3823
3957
|
const normalized = normalizeSourcePath(source);
|
|
3824
3958
|
if (!normalized || /(?:^|\/)node_modules\//.test(normalized)) return null;
|
|
3825
3959
|
if (path9.isAbsolute(normalized) && isFile(normalized)) return path9.resolve(normalized);
|
|
3826
|
-
const
|
|
3960
|
+
const relative8 = normalized.replace(/^\/+/, "");
|
|
3827
3961
|
for (const root of roots) {
|
|
3828
|
-
const candidate = path9.resolve(root,
|
|
3962
|
+
const candidate = path9.resolve(root, relative8);
|
|
3829
3963
|
if (isFile(candidate)) return candidate;
|
|
3830
3964
|
}
|
|
3831
3965
|
return null;
|
|
@@ -3975,6 +4109,871 @@ async function stopCodeReach(page) {
|
|
|
3975
4109
|
}
|
|
3976
4110
|
}
|
|
3977
4111
|
|
|
4112
|
+
// src/internal/capture/resource-ledger.ts
|
|
4113
|
+
var fs8 = __toESM(require("fs"));
|
|
4114
|
+
var path11 = __toESM(require("path"));
|
|
4115
|
+
var import_node_module2 = require("module");
|
|
4116
|
+
|
|
4117
|
+
// src/internal/capture/worker-metrics.ts
|
|
4118
|
+
var import_node_perf_hooks = require("perf_hooks");
|
|
4119
|
+
|
|
4120
|
+
// src/internal/support/system-readers.ts
|
|
4121
|
+
var fs7 = __toESM(require("fs"));
|
|
4122
|
+
var path10 = __toESM(require("path"));
|
|
4123
|
+
var CLK_TCK = 100;
|
|
4124
|
+
function readText(file) {
|
|
4125
|
+
try {
|
|
4126
|
+
return fs7.readFileSync(file, "utf8");
|
|
4127
|
+
} catch {
|
|
4128
|
+
return null;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
function parseProcStat(pid, text) {
|
|
4132
|
+
const open = text.indexOf("(");
|
|
4133
|
+
const close = text.lastIndexOf(")");
|
|
4134
|
+
if (open < 0 || close < open) return null;
|
|
4135
|
+
const fields = text.slice(close + 2).trim().split(/\s+/);
|
|
4136
|
+
const ppid = Number(fields[1]);
|
|
4137
|
+
const utime = Number(fields[11]);
|
|
4138
|
+
const stime = Number(fields[12]);
|
|
4139
|
+
const start = Number(fields[19]);
|
|
4140
|
+
if (![ppid, utime, stime, start].every(Number.isFinite)) return null;
|
|
4141
|
+
return { pid, ppid, comm: text.slice(open + 1, close), cpuMs: (utime + stime) * 1e3 / CLK_TCK, start };
|
|
4142
|
+
}
|
|
4143
|
+
function parseSchedstat(text) {
|
|
4144
|
+
const waitNs = Number(text.trim().split(/\s+/)[1]);
|
|
4145
|
+
return Number.isFinite(waitNs) ? waitNs / 1e6 : null;
|
|
4146
|
+
}
|
|
4147
|
+
function parseKbField(text, key) {
|
|
4148
|
+
const match = new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(text);
|
|
4149
|
+
return match ? Number(match[1]) : null;
|
|
4150
|
+
}
|
|
4151
|
+
function parseCpuTicks(text) {
|
|
4152
|
+
const line = text.split("\n", 1)[0] ?? "";
|
|
4153
|
+
if (!line.startsWith("cpu ")) return null;
|
|
4154
|
+
const v = line.trim().split(/\s+/).slice(1, 9).map(Number);
|
|
4155
|
+
if (v.length < 8 || v.some((n) => !Number.isFinite(n))) return null;
|
|
4156
|
+
const [user, nice, system, idle, iowait, irq, softirq, steal] = v;
|
|
4157
|
+
return { busy: user + nice + system + irq + softirq, idle, iowait, steal, total: v.reduce((sum, n) => sum + n, 0) };
|
|
4158
|
+
}
|
|
4159
|
+
function parsePsiTotal(text) {
|
|
4160
|
+
const match = /^some\b.*\btotal=(\d+)/m.exec(text);
|
|
4161
|
+
return match ? Number(match[1]) : null;
|
|
4162
|
+
}
|
|
4163
|
+
function roleOf(comm, cmdline) {
|
|
4164
|
+
const command = `${comm} ${cmdline}`;
|
|
4165
|
+
if (/workerProcessEntry/.test(cmdline)) return "worker";
|
|
4166
|
+
if (/loaderProcessEntry/.test(cmdline)) return "runner";
|
|
4167
|
+
if (/(^|[\s/])ffmpeg/.test(command)) return "ffmpeg";
|
|
4168
|
+
const type = /--type=([\w-]+)/.exec(cmdline)?.[1];
|
|
4169
|
+
if (/chrom|headless_shell|msedge|Google Chrome/i.test(command)) {
|
|
4170
|
+
if (!type) return "browser";
|
|
4171
|
+
if (type === "renderer") return "renderer";
|
|
4172
|
+
if (type === "gpu-process") return "gpu";
|
|
4173
|
+
return "utility";
|
|
4174
|
+
}
|
|
4175
|
+
if (/firefox/i.test(command)) return /-contentproc/.test(cmdline) ? "renderer" : "browser";
|
|
4176
|
+
if (/WebKitWebProcess/.test(command)) return "renderer";
|
|
4177
|
+
if (/WebKitGPUProcess/.test(command)) return "gpu";
|
|
4178
|
+
if (/WebKitNetworkProcess/.test(command)) return "utility";
|
|
4179
|
+
if (/MiniBrowser|pw_run\.sh/.test(command)) return "browser";
|
|
4180
|
+
return null;
|
|
4181
|
+
}
|
|
4182
|
+
function descendantsOf(root, table) {
|
|
4183
|
+
const children = /* @__PURE__ */ new Map();
|
|
4184
|
+
for (const proc of table) {
|
|
4185
|
+
const list = children.get(proc.ppid) ?? [];
|
|
4186
|
+
list.push(proc);
|
|
4187
|
+
children.set(proc.ppid, list);
|
|
4188
|
+
}
|
|
4189
|
+
const out = [];
|
|
4190
|
+
const stack = [root];
|
|
4191
|
+
const seen = /* @__PURE__ */ new Set([root]);
|
|
4192
|
+
while (stack.length > 0) {
|
|
4193
|
+
for (const child of children.get(stack.pop()) ?? []) {
|
|
4194
|
+
if (seen.has(child.pid)) continue;
|
|
4195
|
+
seen.add(child.pid);
|
|
4196
|
+
out.push(child);
|
|
4197
|
+
stack.push(child.pid);
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
return out;
|
|
4201
|
+
}
|
|
4202
|
+
var ProcFs = class {
|
|
4203
|
+
constructor(root = "/proc") {
|
|
4204
|
+
this.root = root;
|
|
4205
|
+
}
|
|
4206
|
+
/** Whether this looks like a Linux `/proc` at all. */
|
|
4207
|
+
available() {
|
|
4208
|
+
return fs7.existsSync(path10.join(this.root, "stat"));
|
|
4209
|
+
}
|
|
4210
|
+
/** Every process, keyed by pid. */
|
|
4211
|
+
table() {
|
|
4212
|
+
const out = /* @__PURE__ */ new Map();
|
|
4213
|
+
let names;
|
|
4214
|
+
try {
|
|
4215
|
+
names = fs7.readdirSync(this.root);
|
|
4216
|
+
} catch {
|
|
4217
|
+
return out;
|
|
4218
|
+
}
|
|
4219
|
+
for (const name of names) {
|
|
4220
|
+
if (!/^\d+$/.test(name)) continue;
|
|
4221
|
+
const text = readText(path10.join(this.root, name, "stat"));
|
|
4222
|
+
const stat = text ? parseProcStat(Number(name), text) : null;
|
|
4223
|
+
if (stat) out.set(stat.pid, stat);
|
|
4224
|
+
}
|
|
4225
|
+
return out;
|
|
4226
|
+
}
|
|
4227
|
+
stat(pid) {
|
|
4228
|
+
const text = readText(path10.join(this.root, String(pid), "stat"));
|
|
4229
|
+
return text ? parseProcStat(pid, text) : null;
|
|
4230
|
+
}
|
|
4231
|
+
runWaitMs(pid) {
|
|
4232
|
+
const text = readText(path10.join(this.root, String(pid), "schedstat"));
|
|
4233
|
+
return text ? parseSchedstat(text) : null;
|
|
4234
|
+
}
|
|
4235
|
+
cmdline(pid) {
|
|
4236
|
+
return (readText(path10.join(this.root, String(pid), "cmdline")) ?? "").split("\0").join(" ").trim();
|
|
4237
|
+
}
|
|
4238
|
+
/** A field of `/proc/<pid>/status`, in kB: `VmRSS`, `VmHWM`. */
|
|
4239
|
+
statusKb(pid, key) {
|
|
4240
|
+
const text = readText(path10.join(this.root, String(pid), "status"));
|
|
4241
|
+
return text ? parseKbField(text, key) : null;
|
|
4242
|
+
}
|
|
4243
|
+
/** Proportional set size: shared pages split between the processes sharing them, so a sum counts them once. */
|
|
4244
|
+
async pssKb(pid) {
|
|
4245
|
+
try {
|
|
4246
|
+
return parseKbField(await fs7.promises.readFile(path10.join(this.root, String(pid), "smaps_rollup"), "utf8"), "Pss");
|
|
4247
|
+
} catch {
|
|
4248
|
+
return null;
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
/** Reset a process's peak RSS (`VmHWM`) to its current RSS. */
|
|
4252
|
+
resetPeakRss(pid) {
|
|
4253
|
+
try {
|
|
4254
|
+
fs7.writeFileSync(path10.join(this.root, String(pid), "clear_refs"), "5");
|
|
4255
|
+
return true;
|
|
4256
|
+
} catch {
|
|
4257
|
+
return false;
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
cpuTicks() {
|
|
4261
|
+
const text = readText(path10.join(this.root, "stat"));
|
|
4262
|
+
return text ? parseCpuTicks(text) : null;
|
|
4263
|
+
}
|
|
4264
|
+
/** Total stall time of a resource's pressure file, in µs. */
|
|
4265
|
+
pressureUs(resource) {
|
|
4266
|
+
const text = readText(path10.join(this.root, "pressure", resource));
|
|
4267
|
+
return text ? parsePsiTotal(text) : null;
|
|
4268
|
+
}
|
|
4269
|
+
memAvailableKb() {
|
|
4270
|
+
const text = readText(path10.join(this.root, "meminfo"));
|
|
4271
|
+
return text ? parseKbField(text, "MemAvailable") : null;
|
|
4272
|
+
}
|
|
4273
|
+
/** Open file descriptors of a process. */
|
|
4274
|
+
fdCount(pid) {
|
|
4275
|
+
try {
|
|
4276
|
+
return fs7.readdirSync(path10.join(this.root, String(pid), "fd")).length;
|
|
4277
|
+
} catch {
|
|
4278
|
+
return null;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
};
|
|
4282
|
+
var UNLIMITED = 2 ** 60;
|
|
4283
|
+
|
|
4284
|
+
// src/internal/capture/worker-metrics.ts
|
|
4285
|
+
var MB = 1024 * 1024;
|
|
4286
|
+
function round1(value) {
|
|
4287
|
+
return Math.round(value * 10) / 10;
|
|
4288
|
+
}
|
|
4289
|
+
var WorkerMetrics = class {
|
|
4290
|
+
constructor(proc = new ProcFs(), pid = process.pid, platform = process.platform) {
|
|
4291
|
+
this.proc = proc;
|
|
4292
|
+
this.pid = pid;
|
|
4293
|
+
this.loop = null;
|
|
4294
|
+
this.usage = null;
|
|
4295
|
+
/** Each process's CPU and wait at the start of the current window; null before the first. */
|
|
4296
|
+
this.subtree = null;
|
|
4297
|
+
this.roles = /* @__PURE__ */ new Map();
|
|
4298
|
+
this.linux = platform === "linux" && proc.available();
|
|
4299
|
+
let histogram = null;
|
|
4300
|
+
try {
|
|
4301
|
+
histogram = (0, import_node_perf_hooks.monitorEventLoopDelay)({ resolution: 20 });
|
|
4302
|
+
histogram.enable();
|
|
4303
|
+
} catch {
|
|
4304
|
+
histogram = null;
|
|
4305
|
+
}
|
|
4306
|
+
this.histogram = histogram;
|
|
4307
|
+
}
|
|
4308
|
+
/** The worker's processes under it, keyed by pid and start time. */
|
|
4309
|
+
tree() {
|
|
4310
|
+
const table = this.proc.table();
|
|
4311
|
+
return descendantsOf(this.pid, table.values()).map((stat) => {
|
|
4312
|
+
const key = `${stat.pid}:${stat.start}`;
|
|
4313
|
+
let role = this.roles.get(key);
|
|
4314
|
+
if (!role) {
|
|
4315
|
+
role = roleOf(stat.comm, this.proc.cmdline(stat.pid)) ?? "other";
|
|
4316
|
+
this.roles.set(key, role);
|
|
4317
|
+
}
|
|
4318
|
+
return { key, pid: stat.pid, cpuMs: stat.cpuMs, role };
|
|
4319
|
+
});
|
|
4320
|
+
}
|
|
4321
|
+
/**
|
|
4322
|
+
* The processes under the worker now, and the reset of their peak memory: the
|
|
4323
|
+
* start of the next window. Taken once per test, at its end, so the time
|
|
4324
|
+
* between two tests counts toward the next one.
|
|
4325
|
+
*/
|
|
4326
|
+
snapshot() {
|
|
4327
|
+
this.subtree = /* @__PURE__ */ new Map();
|
|
4328
|
+
for (const proc of this.tree()) {
|
|
4329
|
+
this.subtree.set(proc.key, { cpuMs: proc.cpuMs, waitMs: this.proc.runWaitMs(proc.pid) });
|
|
4330
|
+
this.proc.resetPeakRss(proc.pid);
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
/**
|
|
4334
|
+
* Start a test's window. `processes` says whether the worker has started any
|
|
4335
|
+
* browser to read; a worker that never did skips the process tree.
|
|
4336
|
+
*/
|
|
4337
|
+
start(processes = true) {
|
|
4338
|
+
try {
|
|
4339
|
+
this.histogram?.reset();
|
|
4340
|
+
this.loop = import_node_perf_hooks.performance.eventLoopUtilization();
|
|
4341
|
+
this.usage = process.resourceUsage();
|
|
4342
|
+
if (this.linux && processes && !this.subtree) this.snapshot();
|
|
4343
|
+
} catch {
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
/** Close the window. Null when the test's start was never read. */
|
|
4347
|
+
end(processes = true) {
|
|
4348
|
+
if (!this.usage || !this.loop) return null;
|
|
4349
|
+
try {
|
|
4350
|
+
const usage = process.resourceUsage();
|
|
4351
|
+
const loop = import_node_perf_hooks.performance.eventLoopUtilization(this.loop);
|
|
4352
|
+
const metrics = {
|
|
4353
|
+
worker: {
|
|
4354
|
+
cpuMs: Math.round(
|
|
4355
|
+
(usage.userCPUTime - this.usage.userCPUTime + usage.systemCPUTime - this.usage.systemCPUTime) / 1e3
|
|
4356
|
+
),
|
|
4357
|
+
involuntarySwitches: usage.involuntaryContextSwitches - this.usage.involuntaryContextSwitches,
|
|
4358
|
+
loopUtilization: Math.round(loop.utilization * 1e3) / 1e3,
|
|
4359
|
+
loopDelayP99Ms: this.histogram ? round1(this.histogram.percentile(99) / 1e6) : 0,
|
|
4360
|
+
loopDelayMaxMs: this.histogram ? round1(this.histogram.max / 1e6) : 0,
|
|
4361
|
+
heapUsedMb: round1(process.memoryUsage().heapUsed / MB),
|
|
4362
|
+
fds: this.linux ? this.proc.fdCount(this.pid) : null
|
|
4363
|
+
}
|
|
4364
|
+
};
|
|
4365
|
+
this.usage = null;
|
|
4366
|
+
this.loop = null;
|
|
4367
|
+
if (this.linux && processes) metrics.roles = this.roleCosts();
|
|
4368
|
+
return metrics;
|
|
4369
|
+
} catch {
|
|
4370
|
+
return null;
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
/** The window's cost by role; what it reads becomes the start of the next window. */
|
|
4374
|
+
roleCosts() {
|
|
4375
|
+
const roles = {};
|
|
4376
|
+
const start = this.subtree ?? /* @__PURE__ */ new Map();
|
|
4377
|
+
const next = /* @__PURE__ */ new Map();
|
|
4378
|
+
for (const proc of this.tree()) {
|
|
4379
|
+
const from = start.get(proc.key) ?? { cpuMs: 0, waitMs: 0 };
|
|
4380
|
+
const waitMs = this.proc.runWaitMs(proc.pid);
|
|
4381
|
+
const peakKb = this.proc.statusKb(proc.pid, "VmHWM");
|
|
4382
|
+
const cost = roles[proc.role] ??= { cpuMs: 0, runWaitMs: null, peakRssMb: null, processes: 0 };
|
|
4383
|
+
cost.cpuMs += Math.max(0, proc.cpuMs - from.cpuMs);
|
|
4384
|
+
if (waitMs !== null && from.waitMs !== null)
|
|
4385
|
+
cost.runWaitMs = round1((cost.runWaitMs ?? 0) + Math.max(0, waitMs - from.waitMs));
|
|
4386
|
+
if (peakKb !== null) cost.peakRssMb = Math.max(cost.peakRssMb ?? 0, round1(peakKb / 1024));
|
|
4387
|
+
cost.processes++;
|
|
4388
|
+
next.set(proc.key, { cpuMs: proc.cpuMs, waitMs });
|
|
4389
|
+
this.proc.resetPeakRss(proc.pid);
|
|
4390
|
+
}
|
|
4391
|
+
this.subtree = next;
|
|
4392
|
+
return roles;
|
|
4393
|
+
}
|
|
4394
|
+
dispose() {
|
|
4395
|
+
this.histogram?.disable();
|
|
4396
|
+
}
|
|
4397
|
+
};
|
|
4398
|
+
var PageReader = class {
|
|
4399
|
+
constructor() {
|
|
4400
|
+
this.sessions = /* @__PURE__ */ new WeakMap();
|
|
4401
|
+
}
|
|
4402
|
+
async read(page) {
|
|
4403
|
+
let session = this.sessions.get(page);
|
|
4404
|
+
if (!session) {
|
|
4405
|
+
session = internalCall(page, async () => {
|
|
4406
|
+
const opened2 = await page.context().newCDPSession(page);
|
|
4407
|
+
await opened2.send("Performance.enable", { timeDomain: "threadTicks" });
|
|
4408
|
+
return opened2;
|
|
4409
|
+
}).catch(() => null);
|
|
4410
|
+
this.sessions.set(page, session);
|
|
4411
|
+
}
|
|
4412
|
+
const opened = await session;
|
|
4413
|
+
if (!opened) return null;
|
|
4414
|
+
try {
|
|
4415
|
+
const result = await internalCall(page, () => opened.send("Performance.getMetrics"));
|
|
4416
|
+
const values = new Map((result.metrics ?? []).map((metric) => [metric.name, metric.value]));
|
|
4417
|
+
return {
|
|
4418
|
+
cpuMs: Math.round((values.get("TaskDuration") ?? 0) * 1e3),
|
|
4419
|
+
heapMb: round1((values.get("JSHeapUsedSize") ?? 0) / MB),
|
|
4420
|
+
nodes: values.get("Nodes") ?? 0,
|
|
4421
|
+
listeners: values.get("JSEventListeners") ?? 0
|
|
4422
|
+
};
|
|
4423
|
+
} catch {
|
|
4424
|
+
return null;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
};
|
|
4428
|
+
|
|
4429
|
+
// src/internal/capture/resource-ledger.ts
|
|
4430
|
+
var TRACKED_HANDLE_TYPES = ["TCPServerWrap", "FSEventWrap", "StatWatcher"];
|
|
4431
|
+
var USE_METHODS = ["evaluate", "evaluateHandle", "$eval", "$$eval", "waitForFunction", "addScriptTag", "addStyleTag"];
|
|
4432
|
+
var CLOSE_TIMEOUT_MS = 5e3;
|
|
4433
|
+
var PAGE_READ_CAP = 50;
|
|
4434
|
+
var PAGE_READ_TIMEOUT_MS = 1e3;
|
|
4435
|
+
var HOOK_PHASES = /* @__PURE__ */ new Set(["beforeAll", "afterAll", "beforeEach", "afterEach"]);
|
|
4436
|
+
var KIND_ORDER = { browser: 0, context: 1, page: 2, request: 3 };
|
|
4437
|
+
function relativePath(file) {
|
|
4438
|
+
return path11.relative(process.cwd(), file.replace(/^file:\/\//, "")).split(path11.sep).join("/");
|
|
4439
|
+
}
|
|
4440
|
+
function isLibraryFile(file) {
|
|
4441
|
+
return file.startsWith("node:") || file.startsWith("internal/") || file.includes(`${path11.sep}node_modules${path11.sep}`) || file.includes("/node_modules/") || file === __filename;
|
|
4442
|
+
}
|
|
4443
|
+
function userSite(stack) {
|
|
4444
|
+
for (const line of (stack ?? "").split("\n").slice(1)) {
|
|
4445
|
+
const match = /^\s*at\s+(?:.*?\s+\()?(.+?):(\d+):\d+\)?$/.exec(line);
|
|
4446
|
+
if (!match) continue;
|
|
4447
|
+
const file = match[1].replace(/^file:\/\//, "");
|
|
4448
|
+
if (isLibraryFile(file) || !/\.[a-z]+$/i.test(file)) continue;
|
|
4449
|
+
return `${relativePath(file)}:${match[2]}`;
|
|
4450
|
+
}
|
|
4451
|
+
return null;
|
|
4452
|
+
}
|
|
4453
|
+
function zoneSite(zone) {
|
|
4454
|
+
for (const frame of zone?.frames ?? []) {
|
|
4455
|
+
if (!frame?.file || isLibraryFile(frame.file)) continue;
|
|
4456
|
+
return `${relativePath(frame.file)}:${frame.line ?? 0}`;
|
|
4457
|
+
}
|
|
4458
|
+
return null;
|
|
4459
|
+
}
|
|
4460
|
+
function testRef2(info) {
|
|
4461
|
+
if (!info?.testId) return null;
|
|
4462
|
+
const titlePath = Array.isArray(info.titlePath) ? info.titlePath : [];
|
|
4463
|
+
return {
|
|
4464
|
+
id: info.testId,
|
|
4465
|
+
file: info.file ? relativePath(info.file) : "",
|
|
4466
|
+
suite: titlePath.slice(1, -1).map(String)
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
function runnableOf(info) {
|
|
4470
|
+
if (!info) return { phase: "worker", fixture: null };
|
|
4471
|
+
const runnable = info._timeoutManager?._running?.runnable;
|
|
4472
|
+
const type = typeof runnable?.type === "string" ? runnable.type : "test";
|
|
4473
|
+
const phase = HOOK_PHASES.has(type) ? type : type === "teardown" ? "worker" : "test";
|
|
4474
|
+
const raw = runnable?.fixture;
|
|
4475
|
+
const fixture = raw && typeof raw.title === "string" ? {
|
|
4476
|
+
title: raw.title,
|
|
4477
|
+
location: raw.location?.file ? `${relativePath(raw.location.file)}:${raw.location.line ?? 0}` : null,
|
|
4478
|
+
worker: raw.slot !== void 0 && raw.slot !== null
|
|
4479
|
+
} : null;
|
|
4480
|
+
return { phase, fixture };
|
|
4481
|
+
}
|
|
4482
|
+
function fallbackRunnable(window) {
|
|
4483
|
+
return { phase: window ? "test" : "worker", fixture: null };
|
|
4484
|
+
}
|
|
4485
|
+
function playwrightTestInfoSource() {
|
|
4486
|
+
try {
|
|
4487
|
+
const packageJson = resolvePlaywrightPackageJson();
|
|
4488
|
+
if (!packageJson) return () => null;
|
|
4489
|
+
const globals = (0, import_node_module2.createRequire)(packageJson)(path11.join(path11.dirname(packageJson), "lib", "globals.js"));
|
|
4490
|
+
const read = globals.currentTestInfo;
|
|
4491
|
+
if (typeof read !== "function") return () => null;
|
|
4492
|
+
return () => {
|
|
4493
|
+
try {
|
|
4494
|
+
return read() ?? null;
|
|
4495
|
+
} catch {
|
|
4496
|
+
return null;
|
|
4497
|
+
}
|
|
4498
|
+
};
|
|
4499
|
+
} catch {
|
|
4500
|
+
return () => null;
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
function readLeakCheck(value = process.env[PIWI_ENV_KEYS.leakCheck]) {
|
|
4504
|
+
return value === "fail" || value === "close" ? value : "report";
|
|
4505
|
+
}
|
|
4506
|
+
function countHandles() {
|
|
4507
|
+
const counts = {};
|
|
4508
|
+
const read = process.getActiveResourcesInfo;
|
|
4509
|
+
if (typeof read !== "function") return counts;
|
|
4510
|
+
for (const type of read.call(process)) {
|
|
4511
|
+
if (TRACKED_HANDLE_TYPES.includes(type)) counts[type] = (counts[type] ?? 0) + 1;
|
|
4512
|
+
}
|
|
4513
|
+
return counts;
|
|
4514
|
+
}
|
|
4515
|
+
async function settledHandles(start) {
|
|
4516
|
+
const end = countHandles();
|
|
4517
|
+
if (!Object.entries(end).some(([type, n]) => n > (start[type] ?? 0))) return end;
|
|
4518
|
+
await new Promise((resolve3) => setTimeout(resolve3, 0));
|
|
4519
|
+
return countHandles();
|
|
4520
|
+
}
|
|
4521
|
+
function settleWithin(promise, ms) {
|
|
4522
|
+
let timer;
|
|
4523
|
+
return Promise.race([
|
|
4524
|
+
promise.catch(() => void 0),
|
|
4525
|
+
new Promise((resolve3) => {
|
|
4526
|
+
timer = setTimeout(resolve3, ms);
|
|
4527
|
+
})
|
|
4528
|
+
]).finally(() => clearTimeout(timer));
|
|
4529
|
+
}
|
|
4530
|
+
var ResourceLedger = class {
|
|
4531
|
+
constructor(playwright, workerIndex, leakCheck = readLeakCheck(), resultsFile = process.env[PIWI_RESOURCES_RESULTS_ENV] || null, testInfoSource = playwrightTestInfoSource(), now = Date.now, readers = {}) {
|
|
4532
|
+
this.playwright = playwright;
|
|
4533
|
+
this.workerIndex = workerIndex;
|
|
4534
|
+
this.leakCheck = leakCheck;
|
|
4535
|
+
this.resultsFile = resultsFile;
|
|
4536
|
+
this.testInfoSource = testInfoSource;
|
|
4537
|
+
this.now = now;
|
|
4538
|
+
this.readers = readers;
|
|
4539
|
+
this.nextId = 1;
|
|
4540
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
4541
|
+
this.byObject = /* @__PURE__ */ new WeakMap();
|
|
4542
|
+
this.pendingCalls = /* @__PURE__ */ new Set();
|
|
4543
|
+
this.lastCall = null;
|
|
4544
|
+
/** The page a wrapped page method is calling into: its first reported API call marks it used. */
|
|
4545
|
+
this.usingPage = null;
|
|
4546
|
+
this.born = [];
|
|
4547
|
+
this.closed = [];
|
|
4548
|
+
this.handlesAtStart = {};
|
|
4549
|
+
this.openAtStart = { contexts: 0, pages: 0 };
|
|
4550
|
+
/** The test between `testStarted` and `testEnded`, for when Playwright's own test info cannot be read. */
|
|
4551
|
+
this.window = null;
|
|
4552
|
+
this.listener = null;
|
|
4553
|
+
/** A browser or a context was opened in this worker: there are browser processes for the metrics to read. */
|
|
4554
|
+
this.sawBrowser = false;
|
|
4555
|
+
}
|
|
4556
|
+
/** Listen to the worker's Playwright and wrap its browser types. False when the instrumentation is missing. */
|
|
4557
|
+
install() {
|
|
4558
|
+
const instrumentation = this.playwright?._instrumentation;
|
|
4559
|
+
if (!instrumentation || typeof instrumentation.addListener !== "function") return false;
|
|
4560
|
+
this.listener = {
|
|
4561
|
+
onApiCallBegin: (zone) => {
|
|
4562
|
+
if (this.pendingCalls.size > 1e3) this.pendingCalls.clear();
|
|
4563
|
+
this.pendingCalls.add(zone);
|
|
4564
|
+
this.lastCall = zone;
|
|
4565
|
+
if (this.usingPage) {
|
|
4566
|
+
this.usingPage.used = true;
|
|
4567
|
+
this.usingPage = null;
|
|
4568
|
+
}
|
|
4569
|
+
},
|
|
4570
|
+
onApiCallEnd: (zone) => {
|
|
4571
|
+
this.pendingCalls.delete(zone);
|
|
4572
|
+
},
|
|
4573
|
+
runAfterCreateBrowserContext: async (context) => {
|
|
4574
|
+
this.guard(() => this.onContext(context, userSite(new Error().stack)));
|
|
4575
|
+
},
|
|
4576
|
+
runAfterCreateRequestContext: async (request) => {
|
|
4577
|
+
this.guard(() => this.track(request, "request", null, userSite(new Error().stack)));
|
|
4578
|
+
},
|
|
4579
|
+
runBeforeCloseRequestContext: async (request) => {
|
|
4580
|
+
this.guard(() => this.onClosed(request));
|
|
4581
|
+
}
|
|
4582
|
+
};
|
|
4583
|
+
instrumentation.addListener(this.listener);
|
|
4584
|
+
for (const type of [this.playwright.chromium, this.playwright.firefox, this.playwright.webkit]) {
|
|
4585
|
+
if (type) this.wrapBrowserType(type);
|
|
4586
|
+
}
|
|
4587
|
+
return true;
|
|
4588
|
+
}
|
|
4589
|
+
/** Stop listening; the objects already tracked stay in the ledger. */
|
|
4590
|
+
uninstall() {
|
|
4591
|
+
const remove = this.playwright?._instrumentation?.removeListener;
|
|
4592
|
+
if (this.listener && typeof remove === "function") remove.call(this.playwright._instrumentation, this.listener);
|
|
4593
|
+
this.listener = null;
|
|
4594
|
+
}
|
|
4595
|
+
/** Mark a page used: a locator was built on it. */
|
|
4596
|
+
notePageUse(page) {
|
|
4597
|
+
const entry = this.byObject.get(page);
|
|
4598
|
+
if (entry) entry.used = true;
|
|
4599
|
+
}
|
|
4600
|
+
/** Open the census window of a test. */
|
|
4601
|
+
testStarted(info) {
|
|
4602
|
+
this.window = testRef2(info);
|
|
4603
|
+
this.handlesAtStart = countHandles();
|
|
4604
|
+
this.openAtStart = { contexts: 0, pages: 0 };
|
|
4605
|
+
for (const entry of this.openEntries()) {
|
|
4606
|
+
if (entry.birth.kind === "page") this.openAtStart.pages++;
|
|
4607
|
+
else if (entry.birth.kind === "context") this.openAtStart.contexts++;
|
|
4608
|
+
}
|
|
4609
|
+
this.readers.metrics?.start(this.sawBrowser);
|
|
4610
|
+
}
|
|
4611
|
+
/**
|
|
4612
|
+
* Close the census window of a test: apply `PIWI_LEAK_CHECK=close` to what the
|
|
4613
|
+
* test left open, then take the census. Called after the test-scoped fixtures
|
|
4614
|
+
* tore down, so what the test opened through them is closed by now.
|
|
4615
|
+
*/
|
|
4616
|
+
async testEnded(info) {
|
|
4617
|
+
const test = testRef2(info) ?? this.window;
|
|
4618
|
+
const failed = typeof info.status === "string" && typeof info.expectedStatus === "string" && info.status !== info.expectedStatus;
|
|
4619
|
+
const leaks = test && !failed ? this.leaksOf(test.id) : [];
|
|
4620
|
+
if (this.leakCheck === "close" && leaks.length > 0) await this.closeLeaks(leaks);
|
|
4621
|
+
const ref = test ? { ...test, title: typeof info.title === "string" ? info.title : "", ...failed ? { failed: true } : {} } : null;
|
|
4622
|
+
const metrics = this.readers.metrics?.end(this.sawBrowser) ?? null;
|
|
4623
|
+
const handles = { start: this.handlesAtStart, end: await settledHandles(this.handlesAtStart) };
|
|
4624
|
+
const census = this.census(ref, handles, await this.readPages());
|
|
4625
|
+
if (metrics) census.metrics = metrics;
|
|
4626
|
+
if (test) {
|
|
4627
|
+
census.openAtStart = this.openAtStart;
|
|
4628
|
+
if (!failed) census.leftOpen = leaks.length;
|
|
4629
|
+
}
|
|
4630
|
+
this.window = null;
|
|
4631
|
+
return { census, leaks };
|
|
4632
|
+
}
|
|
4633
|
+
/** The open pages' main thread and weight, over CDP, within a deadline. */
|
|
4634
|
+
async readPages() {
|
|
4635
|
+
const out = /* @__PURE__ */ new Map();
|
|
4636
|
+
const reader = this.readers.pages;
|
|
4637
|
+
if (!reader) return out;
|
|
4638
|
+
const pages = this.openEntries().filter((entry) => entry.birth.kind === "page").slice(0, PAGE_READ_CAP);
|
|
4639
|
+
await settleWithin(
|
|
4640
|
+
Promise.all(
|
|
4641
|
+
pages.map(async (entry) => {
|
|
4642
|
+
const main = entry.object ? await reader.read(entry.object) : null;
|
|
4643
|
+
if (main) out.set(entry.birth.id, main);
|
|
4644
|
+
})
|
|
4645
|
+
),
|
|
4646
|
+
PAGE_READ_TIMEOUT_MS
|
|
4647
|
+
);
|
|
4648
|
+
return out;
|
|
4649
|
+
}
|
|
4650
|
+
/** The census a worker takes as it shuts down, appended to the results file when the reporter set one. */
|
|
4651
|
+
workerEnded() {
|
|
4652
|
+
const census = this.census(null);
|
|
4653
|
+
if (this.resultsFile) {
|
|
4654
|
+
try {
|
|
4655
|
+
fs8.appendFileSync(this.resultsFile, `${JSON.stringify(census)}
|
|
4656
|
+
`);
|
|
4657
|
+
} catch {
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
this.readers.metrics?.dispose();
|
|
4661
|
+
this.uninstall();
|
|
4662
|
+
return census;
|
|
4663
|
+
}
|
|
4664
|
+
guard(run) {
|
|
4665
|
+
try {
|
|
4666
|
+
run();
|
|
4667
|
+
} catch {
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
wrapBrowserType(type) {
|
|
4671
|
+
for (const method of ["launch", "connect", "connectOverCDP"]) {
|
|
4672
|
+
const original = type[method];
|
|
4673
|
+
if (typeof original !== "function") continue;
|
|
4674
|
+
const bound = original.bind(type);
|
|
4675
|
+
type[method] = async (...args) => {
|
|
4676
|
+
const site = userSite(new Error().stack);
|
|
4677
|
+
const browser = await bound(...args);
|
|
4678
|
+
this.guard(() => this.onBrowser(browser, site));
|
|
4679
|
+
return browser;
|
|
4680
|
+
};
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
track(object, kind, parent, site) {
|
|
4684
|
+
const existing = this.byObject.get(object);
|
|
4685
|
+
if (existing) return existing;
|
|
4686
|
+
const info = this.testInfoSource();
|
|
4687
|
+
const { phase, fixture } = info ? runnableOf(info) : fallbackRunnable(this.window);
|
|
4688
|
+
const birth = {
|
|
4689
|
+
id: this.nextId++,
|
|
4690
|
+
kind,
|
|
4691
|
+
parent: parent ? this.byObject.get(parent)?.birth.id ?? null : null,
|
|
4692
|
+
at: this.now(),
|
|
4693
|
+
phase,
|
|
4694
|
+
fixture,
|
|
4695
|
+
test: testRef2(info) ?? this.window,
|
|
4696
|
+
site
|
|
4697
|
+
};
|
|
4698
|
+
const entry = { birth, object, closed: null, used: false };
|
|
4699
|
+
if (kind === "browser" || kind === "context") this.sawBrowser = true;
|
|
4700
|
+
this.entries.set(birth.id, entry);
|
|
4701
|
+
this.byObject.set(object, entry);
|
|
4702
|
+
this.born.push(birth);
|
|
4703
|
+
return entry;
|
|
4704
|
+
}
|
|
4705
|
+
onBrowser(browser, site) {
|
|
4706
|
+
const known = this.byObject.get(browser);
|
|
4707
|
+
if (known) return known;
|
|
4708
|
+
const entry = this.track(browser, "browser", null, site);
|
|
4709
|
+
browser.on("disconnected", () => this.guard(() => this.onClosed(browser)));
|
|
4710
|
+
return entry;
|
|
4711
|
+
}
|
|
4712
|
+
onContext(context, site) {
|
|
4713
|
+
if (this.byObject.has(context)) return;
|
|
4714
|
+
const browser = context.browser();
|
|
4715
|
+
if (browser) this.onBrowser(browser, site);
|
|
4716
|
+
const entry = this.track(context, "context", browser, site);
|
|
4717
|
+
const zone = this.latestPendingCall();
|
|
4718
|
+
if (zone?.apiName?.endsWith(".newPage")) entry.birth.implicit = true;
|
|
4719
|
+
if (context._forReuse) entry.birth.reused = true;
|
|
4720
|
+
context.on("close", () => this.guard(() => this.onClosed(context)));
|
|
4721
|
+
context.on("page", (page) => this.guard(() => this.onPage(page, context)));
|
|
4722
|
+
for (const page of context.pages()) this.onPage(page, context, { site });
|
|
4723
|
+
}
|
|
4724
|
+
/** `initial` is set for a page that came with its context (a persistent context's), which is no popup. */
|
|
4725
|
+
onPage(page, context, initial) {
|
|
4726
|
+
if (this.byObject.has(page)) return;
|
|
4727
|
+
const zone = this.latestPendingCall();
|
|
4728
|
+
const viaNewPage = !!zone?.apiName?.endsWith(".newPage");
|
|
4729
|
+
const popup = !viaNewPage && !initial;
|
|
4730
|
+
const site = viaNewPage ? zoneSite(zone) : initial ? initial.site : zoneSite(this.lastCall);
|
|
4731
|
+
const entry = this.track(page, "page", context, site);
|
|
4732
|
+
if (popup) {
|
|
4733
|
+
const opener = page._opener;
|
|
4734
|
+
const openerEntry = opener ? this.byObject.get(opener) : void 0;
|
|
4735
|
+
if (openerEntry) entry.birth.opener = openerEntry.birth.id;
|
|
4736
|
+
if (this.lastCall?.apiName) entry.birth.trigger = this.lastCall.apiName;
|
|
4737
|
+
}
|
|
4738
|
+
const markUsed = () => {
|
|
4739
|
+
entry.used = true;
|
|
4740
|
+
};
|
|
4741
|
+
page.on("load", markUsed);
|
|
4742
|
+
page.on("domcontentloaded", markUsed);
|
|
4743
|
+
page.on("framenavigated", (frame) => {
|
|
4744
|
+
try {
|
|
4745
|
+
if (frame === page.mainFrame() && frame.url() !== "about:blank") entry.used = true;
|
|
4746
|
+
} catch {
|
|
4747
|
+
}
|
|
4748
|
+
});
|
|
4749
|
+
page.on("close", () => this.guard(() => this.onClosed(page)));
|
|
4750
|
+
const methods = page;
|
|
4751
|
+
for (const method of USE_METHODS) {
|
|
4752
|
+
const original = methods[method];
|
|
4753
|
+
if (typeof original !== "function") continue;
|
|
4754
|
+
const bound = original.bind(page);
|
|
4755
|
+
methods[method] = (...args) => {
|
|
4756
|
+
const previous = this.usingPage;
|
|
4757
|
+
this.usingPage = entry;
|
|
4758
|
+
try {
|
|
4759
|
+
return bound(...args);
|
|
4760
|
+
} finally {
|
|
4761
|
+
this.usingPage = previous;
|
|
4762
|
+
}
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
onClosed(object, byPiwi = false) {
|
|
4767
|
+
const entry = this.byObject.get(object);
|
|
4768
|
+
if (!entry || entry.closed) return;
|
|
4769
|
+
const info = this.testInfoSource();
|
|
4770
|
+
const { phase } = info ? runnableOf(info) : fallbackRunnable(this.window);
|
|
4771
|
+
const close = {
|
|
4772
|
+
id: entry.birth.id,
|
|
4773
|
+
at: this.now(),
|
|
4774
|
+
phase,
|
|
4775
|
+
test: testRef2(info) ?? this.window
|
|
4776
|
+
};
|
|
4777
|
+
if (entry.birth.kind === "page") close.used = entry.used;
|
|
4778
|
+
if (byPiwi) close.byPiwi = true;
|
|
4779
|
+
entry.closed = close;
|
|
4780
|
+
entry.object = null;
|
|
4781
|
+
this.closed.push(close);
|
|
4782
|
+
}
|
|
4783
|
+
latestPendingCall() {
|
|
4784
|
+
let latest = null;
|
|
4785
|
+
for (const zone of this.pendingCalls) latest = zone;
|
|
4786
|
+
return latest;
|
|
4787
|
+
}
|
|
4788
|
+
openEntries() {
|
|
4789
|
+
return [...this.entries.values()].filter((entry) => !entry.closed && entry.object);
|
|
4790
|
+
}
|
|
4791
|
+
/**
|
|
4792
|
+
* Objects a test opened itself, by its body or its each-hooks, and left open.
|
|
4793
|
+
* A page in a leaked context, or a context in a leaked browser, goes with it;
|
|
4794
|
+
* the context `browser.newPage()` opened for its page goes with the page.
|
|
4795
|
+
*/
|
|
4796
|
+
leaksOf(testId) {
|
|
4797
|
+
const open = this.openEntries().filter(
|
|
4798
|
+
(entry) => entry.birth.test?.id === testId && entry.birth.fixture === null && (entry.birth.phase === "test" || entry.birth.phase === "beforeEach" || entry.birth.phase === "afterEach") && !entry.birth.reused
|
|
4799
|
+
);
|
|
4800
|
+
const openIds = new Set(open.map((entry) => entry.birth.id));
|
|
4801
|
+
const foldsIntoLeak = (entry) => {
|
|
4802
|
+
let parentId = entry.birth.parent;
|
|
4803
|
+
while (parentId !== null) {
|
|
4804
|
+
if (!this.isImplicit(parentId)) return openIds.has(parentId);
|
|
4805
|
+
parentId = this.entries.get(parentId)?.birth.parent ?? null;
|
|
4806
|
+
}
|
|
4807
|
+
return false;
|
|
4808
|
+
};
|
|
4809
|
+
return open.filter((entry) => !(entry.birth.kind === "context" && this.isImplicit(entry.birth.id))).filter((entry) => !foldsIntoLeak(entry)).map((entry) => ({
|
|
4810
|
+
id: entry.birth.id,
|
|
4811
|
+
kind: entry.birth.kind,
|
|
4812
|
+
site: entry.birth.site,
|
|
4813
|
+
pages: entry.birth.kind === "page" ? 0 : this.pagesUnder(entry.birth.id),
|
|
4814
|
+
implicit: false,
|
|
4815
|
+
...entry.birth.opener !== void 0 ? { trigger: entry.birth.trigger ?? "a click" } : {}
|
|
4816
|
+
})).sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]);
|
|
4817
|
+
}
|
|
4818
|
+
/** Open pages under a context, or under any context of a browser. */
|
|
4819
|
+
pagesUnder(id) {
|
|
4820
|
+
let count = 0;
|
|
4821
|
+
for (const entry of this.openEntries()) {
|
|
4822
|
+
if (entry.birth.kind !== "page" || entry.birth.parent === null) continue;
|
|
4823
|
+
const context = this.entries.get(entry.birth.parent);
|
|
4824
|
+
if (entry.birth.parent === id || context?.birth.parent === id) count++;
|
|
4825
|
+
}
|
|
4826
|
+
return count;
|
|
4827
|
+
}
|
|
4828
|
+
isImplicit(id) {
|
|
4829
|
+
const entry = this.entries.get(id);
|
|
4830
|
+
if (!entry || entry.birth.kind !== "context") return false;
|
|
4831
|
+
if (entry.birth.implicit) return true;
|
|
4832
|
+
const owner = entry.object ? entry.object._ownerPage : void 0;
|
|
4833
|
+
if (owner) entry.birth.implicit = true;
|
|
4834
|
+
return !!owner;
|
|
4835
|
+
}
|
|
4836
|
+
openPagesOf(contextId) {
|
|
4837
|
+
let count = 0;
|
|
4838
|
+
for (const entry of this.openEntries())
|
|
4839
|
+
if (entry.birth.kind === "page" && entry.birth.parent === contextId) count++;
|
|
4840
|
+
return count;
|
|
4841
|
+
}
|
|
4842
|
+
async closeLeaks(leaks) {
|
|
4843
|
+
for (const leak of leaks) {
|
|
4844
|
+
const entry = this.entries.get(leak.id);
|
|
4845
|
+
const object = entry?.object;
|
|
4846
|
+
if (!entry || entry.closed || !object) continue;
|
|
4847
|
+
const close = leak.kind === "request" ? () => object.dispose() : () => object.close();
|
|
4848
|
+
await settleWithin(internalCall(object, close), CLOSE_TIMEOUT_MS);
|
|
4849
|
+
const closedMeanwhile = entry.closed;
|
|
4850
|
+
if (closedMeanwhile) closedMeanwhile.byPiwi = true;
|
|
4851
|
+
else this.onClosed(object, true);
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
census(test, handles, mains = /* @__PURE__ */ new Map()) {
|
|
4855
|
+
const open = [];
|
|
4856
|
+
for (const entry of this.openEntries()) {
|
|
4857
|
+
const object = entry.object;
|
|
4858
|
+
if (entry.birth.kind === "page") {
|
|
4859
|
+
const main = mains.get(entry.birth.id);
|
|
4860
|
+
open.push({
|
|
4861
|
+
id: entry.birth.id,
|
|
4862
|
+
used: entry.used,
|
|
4863
|
+
url: this.pageUrl(object),
|
|
4864
|
+
listeners: this.listenerCount(object),
|
|
4865
|
+
routes: this.routeCount(object),
|
|
4866
|
+
...main ? { main } : {}
|
|
4867
|
+
});
|
|
4868
|
+
} else if (entry.birth.kind === "context") {
|
|
4869
|
+
this.isImplicit(entry.birth.id);
|
|
4870
|
+
open.push({ id: entry.birth.id, pages: this.openPagesOf(entry.birth.id), routes: this.routeCount(object) });
|
|
4871
|
+
} else {
|
|
4872
|
+
open.push({ id: entry.birth.id });
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
const census = {
|
|
4876
|
+
v: 1,
|
|
4877
|
+
worker: this.workerIndex,
|
|
4878
|
+
pid: process.pid,
|
|
4879
|
+
at: this.now(),
|
|
4880
|
+
test,
|
|
4881
|
+
born: this.born,
|
|
4882
|
+
closed: this.closed,
|
|
4883
|
+
open
|
|
4884
|
+
};
|
|
4885
|
+
if (handles) census.handles = handles;
|
|
4886
|
+
this.born = [];
|
|
4887
|
+
this.closed = [];
|
|
4888
|
+
return census;
|
|
4889
|
+
}
|
|
4890
|
+
pageUrl(page) {
|
|
4891
|
+
try {
|
|
4892
|
+
const url = typeof page.url === "function" ? String(page.url()) : "";
|
|
4893
|
+
return url ? url.split(/[?#]/)[0] : void 0;
|
|
4894
|
+
} catch {
|
|
4895
|
+
return void 0;
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
listenerCount(emitter) {
|
|
4899
|
+
try {
|
|
4900
|
+
if (typeof emitter.eventNames !== "function" || typeof emitter.listenerCount !== "function") return void 0;
|
|
4901
|
+
const names = emitter.eventNames();
|
|
4902
|
+
return names.reduce((sum, name) => sum + emitter.listenerCount(name), 0);
|
|
4903
|
+
} catch {
|
|
4904
|
+
return void 0;
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
routeCount(object) {
|
|
4908
|
+
const routes = object._routes;
|
|
4909
|
+
return Array.isArray(routes) ? routes.length : void 0;
|
|
4910
|
+
}
|
|
4911
|
+
};
|
|
4912
|
+
var activeLedger = null;
|
|
4913
|
+
function startResourceLedger(playwright, workerIndex) {
|
|
4914
|
+
if (process.env[PIWI_ENV_KEYS.captureResources] === "false") return null;
|
|
4915
|
+
if (activeLedger) return activeLedger;
|
|
4916
|
+
const metrics = new WorkerMetrics();
|
|
4917
|
+
const ledger = new ResourceLedger(
|
|
4918
|
+
playwright,
|
|
4919
|
+
workerIndex,
|
|
4920
|
+
readLeakCheck(),
|
|
4921
|
+
process.env[PIWI_RESOURCES_RESULTS_ENV] || null,
|
|
4922
|
+
playwrightTestInfoSource(),
|
|
4923
|
+
Date.now,
|
|
4924
|
+
{ metrics, pages: new PageReader() }
|
|
4925
|
+
);
|
|
4926
|
+
if (!ledger.install()) {
|
|
4927
|
+
metrics.dispose();
|
|
4928
|
+
return null;
|
|
4929
|
+
}
|
|
4930
|
+
activeLedger = ledger;
|
|
4931
|
+
return ledger;
|
|
4932
|
+
}
|
|
4933
|
+
function activeResourceLedger() {
|
|
4934
|
+
return activeLedger;
|
|
4935
|
+
}
|
|
4936
|
+
function stopResourceLedger() {
|
|
4937
|
+
const ledger = activeLedger;
|
|
4938
|
+
activeLedger = null;
|
|
4939
|
+
return ledger ? ledger.workerEnded() : null;
|
|
4940
|
+
}
|
|
4941
|
+
function noteResourceUse(page) {
|
|
4942
|
+
activeLedger?.notePageUse(page);
|
|
4943
|
+
}
|
|
4944
|
+
var ARTICLE = {
|
|
4945
|
+
browser: "a browser",
|
|
4946
|
+
context: "a context",
|
|
4947
|
+
page: "a page",
|
|
4948
|
+
request: "an API request context"
|
|
4949
|
+
};
|
|
4950
|
+
function leakFailureMessage(leaks) {
|
|
4951
|
+
const lines = leaks.map((leak) => {
|
|
4952
|
+
const what = leak.trigger ? `a popup opened by ${leak.trigger}` : ARTICLE[leak.kind];
|
|
4953
|
+
const where = leak.site ? `${leak.trigger ? "" : " opened"} at ${leak.site}` : "";
|
|
4954
|
+
const pages = leak.pages > 0 ? `, with ${leak.pages} page${leak.pages === 1 ? "" : "s"}` : "";
|
|
4955
|
+
return ` - ${what}${where}${pages}`;
|
|
4956
|
+
});
|
|
4957
|
+
return [
|
|
4958
|
+
`Piwi: this test left ${leaks.length === 1 ? "one object" : `${leaks.length} objects`} open:`,
|
|
4959
|
+
...lines,
|
|
4960
|
+
"Close each before the test ends (await using, close() or dispose()), or set PIWI_LEAK_CHECK=report to only list them."
|
|
4961
|
+
].join("\n");
|
|
4962
|
+
}
|
|
4963
|
+
async function recordResourceCensus(testInfo) {
|
|
4964
|
+
const ledger = activeLedger;
|
|
4965
|
+
if (!ledger) return;
|
|
4966
|
+
const { census, leaks } = await ledger.testEnded(testInfo);
|
|
4967
|
+
try {
|
|
4968
|
+
await testInfo.attach(ATTACHMENT_NAMES.resources, {
|
|
4969
|
+
contentType: "application/json",
|
|
4970
|
+
body: Buffer.from(JSON.stringify(census))
|
|
4971
|
+
});
|
|
4972
|
+
} catch {
|
|
4973
|
+
}
|
|
4974
|
+
if (ledger.leakCheck === "fail" && leaks.length > 0) throw new Error(leakFailureMessage(leaks));
|
|
4975
|
+
}
|
|
4976
|
+
|
|
3978
4977
|
// src/internal/capture/capture-fixtures.ts
|
|
3979
4978
|
function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
|
|
3980
4979
|
const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
|
|
@@ -4047,7 +5046,7 @@ function configuredCodeReachRoots() {
|
|
|
4047
5046
|
if (Array.isArray(parsed)) return parsed.filter((r) => typeof r === "string" && r !== "");
|
|
4048
5047
|
} catch {
|
|
4049
5048
|
}
|
|
4050
|
-
return raw.split(
|
|
5049
|
+
return raw.split(path12.delimiter).filter(Boolean);
|
|
4051
5050
|
}
|
|
4052
5051
|
async function stopSinkCodeReach(sink, closing) {
|
|
4053
5052
|
const session = sink.codeReach;
|
|
@@ -4062,7 +5061,7 @@ async function stopSinkCodeReach(sink, closing) {
|
|
|
4062
5061
|
if (!entries) return;
|
|
4063
5062
|
const info = sink.testInfo;
|
|
4064
5063
|
const configFile = info?.config.configFile;
|
|
4065
|
-
const configDir = configFile ?
|
|
5064
|
+
const configDir = configFile ? path12.dirname(configFile) : info?.config.rootDir ?? process.cwd();
|
|
4066
5065
|
session.files = await resolveCodeReach(
|
|
4067
5066
|
entries,
|
|
4068
5067
|
codeReachRoots(configDir, configuredCodeReachRoots()),
|
|
@@ -4626,6 +5625,7 @@ function instrumentPage(page) {
|
|
|
4626
5625
|
const original = factories[method].bind(page);
|
|
4627
5626
|
factories[method] = (...args) => {
|
|
4628
5627
|
if (currentSink) currentSink.lastActivePage = page;
|
|
5628
|
+
noteResourceUse(page);
|
|
4629
5629
|
return wrapLocator(page, original(...args), method, args);
|
|
4630
5630
|
};
|
|
4631
5631
|
}
|
|
@@ -4955,6 +5955,22 @@ async function flushSink(sink, testInfo) {
|
|
|
4955
5955
|
}
|
|
4956
5956
|
}
|
|
4957
5957
|
var piwiFixtures = {
|
|
5958
|
+
// Auto, worker-scoped: the worker's resource ledger. Set up before any hook
|
|
5959
|
+
// (worker fixtures come first), so a `beforeAll` context is in it too, and
|
|
5960
|
+
// torn down after the browser closed, so its census sees what only the
|
|
5961
|
+
// worker's shutdown closed. Depends on `playwright` alone, which launches nothing.
|
|
5962
|
+
piwiResources: [
|
|
5963
|
+
async ({ playwright }, use, workerInfo) => {
|
|
5964
|
+
boxCaptureFrames();
|
|
5965
|
+
startResourceLedger(playwright, workerInfo.workerIndex);
|
|
5966
|
+
try {
|
|
5967
|
+
await use();
|
|
5968
|
+
} finally {
|
|
5969
|
+
stopResourceLedger();
|
|
5970
|
+
}
|
|
5971
|
+
},
|
|
5972
|
+
{ scope: "worker", auto: true }
|
|
5973
|
+
],
|
|
4958
5974
|
// Worker-scoped: patch the shared browser so every page/context created from
|
|
4959
5975
|
// it — including by user fixtures that take `browser` directly — is captured.
|
|
4960
5976
|
browser: [
|
|
@@ -5002,6 +6018,7 @@ var piwiFixtures = {
|
|
|
5002
6018
|
file: testInfo.file,
|
|
5003
6019
|
titlePath: testInfo.titlePath
|
|
5004
6020
|
});
|
|
6021
|
+
activeResourceLedger()?.testStarted(testInfo);
|
|
5005
6022
|
currentSink = sink;
|
|
5006
6023
|
try {
|
|
5007
6024
|
await use();
|
|
@@ -5028,6 +6045,7 @@ var piwiFixtures = {
|
|
|
5028
6045
|
}
|
|
5029
6046
|
if (sink.flake) await recordFlakeAttempt(sink.flake, testInfo);
|
|
5030
6047
|
await flushSink(sink, testInfo);
|
|
6048
|
+
await recordResourceCensus(testInfo);
|
|
5031
6049
|
}
|
|
5032
6050
|
},
|
|
5033
6051
|
{ auto: true }
|
|
@@ -5061,6 +6079,7 @@ function extendPiwiFixtures(test) {
|
|
|
5061
6079
|
0 && (module.exports = {
|
|
5062
6080
|
CAPTURED_ATTRS_ARG,
|
|
5063
6081
|
LocatorPageLog,
|
|
6082
|
+
activeResourceLedger,
|
|
5064
6083
|
ariaSnapshotBestEffort,
|
|
5065
6084
|
ariaSnapshotJSONBestEffort,
|
|
5066
6085
|
boxCaptureFrames,
|
|
@@ -5073,10 +6092,14 @@ function extendPiwiFixtures(test) {
|
|
|
5073
6092
|
internalCall,
|
|
5074
6093
|
noteLocatorCall,
|
|
5075
6094
|
noteNavigation,
|
|
6095
|
+
noteResourceUse,
|
|
5076
6096
|
pageMapFetcher,
|
|
5077
6097
|
piwiFixtures,
|
|
5078
6098
|
probeElementAttrs,
|
|
6099
|
+
recordResourceCensus,
|
|
5079
6100
|
resolveCodeReach,
|
|
5080
6101
|
startCodeReach,
|
|
5081
|
-
|
|
6102
|
+
startResourceLedger,
|
|
6103
|
+
stopCodeReach,
|
|
6104
|
+
stopResourceLedger
|
|
5082
6105
|
});
|