@piwitests/reporter 0.45.0 → 0.47.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/index.js CHANGED
@@ -89,6 +89,7 @@ var DEFAULTS = {
89
89
  sampleAriaOnPass: true,
90
90
  uploadManifest: true,
91
91
  defaultCapture: true,
92
+ checkBaseUrl: false,
92
93
  streaming: true,
93
94
  streamingBatchSize: 5,
94
95
  streamingBatchDelay: 2e3,
@@ -131,6 +132,7 @@ var PIWI_ENV_KEYS = {
131
132
  sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
132
133
  uploadManifest: "PIWI_UPLOAD_MANIFEST",
133
134
  defaultCapture: "PIWI_DEFAULT_CAPTURE",
135
+ checkBaseUrl: "PIWI_CHECK_BASE_URL",
134
136
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
135
137
  pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
136
138
  outputFile: "PIWI_OUTPUT_FILE",
@@ -151,6 +153,10 @@ var PIWI_SELECTION_ENV = {
151
153
  hash: "PIWI_SELECTION_HASH",
152
154
  count: "PIWI_SELECTION_COUNT"
153
155
  };
156
+ var PIWI_ORIGIN_ENV = {
157
+ kind: "PIWI_ORIGIN",
158
+ ref: "PIWI_ORIGIN_REF"
159
+ };
154
160
  var PIWI_SHARD_ENV = "PIWI_SHARD";
155
161
  var PIWI_PROBE_ENV = {
156
162
  flag: "PIWI_PROBE",
@@ -196,6 +202,7 @@ var ENV_FALLBACK_SPECS = [
196
202
  { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
197
203
  { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
198
204
  { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
205
+ { option: "checkBaseUrl", env: PIWI_ENV_KEYS.checkBaseUrl, kind: "bool" },
199
206
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
200
207
  { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
201
208
  { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
@@ -243,6 +250,7 @@ function applyOptionsToEnv(options) {
243
250
  if (options.label) env[PIWI_ENV_KEYS.label] = options.label;
244
251
  if (options.runLabel) env[PIWI_ENV_KEYS.runLabel] = options.runLabel;
245
252
  if (options.keep) env[PIWI_ENV_KEYS.keep] = "true";
253
+ if (options.checkBaseUrl !== void 0) env[PIWI_ENV_KEYS.checkBaseUrl] = String(options.checkBaseUrl);
246
254
  if (options.captureLocators === false || options.collectPerformanceMetrics === false)
247
255
  env[PIWI_ENV_KEYS.captureLocators] = "false";
248
256
  else if (options.captureLocators === true) env[PIWI_ENV_KEYS.captureLocators] = "true";
@@ -1008,6 +1016,8 @@ var CrashRecovery = class {
1008
1016
  if (!data) return;
1009
1017
  this.logger.info("Found saved test data from a previous run, uploading...");
1010
1018
  delete data.instanceId;
1019
+ const metadata = data.metadata && typeof data.metadata === "object" ? data.metadata : {};
1020
+ data.metadata = { ...metadata, ingestHealth: { submitFallback: { path: "recovery" } } };
1011
1021
  try {
1012
1022
  await httpClient.postJSON("/api/test-runs/submit", data, auth);
1013
1023
  this.logger.info("Successfully uploaded saved test data");
@@ -1322,6 +1332,35 @@ function formatMiB(bytes) {
1322
1332
 
1323
1333
  // src/internal/collect/metadata-collector.ts
1324
1334
  var import_node_child_process = require("child_process");
1335
+
1336
+ // ../core/src/wire.ts
1337
+ var RUN_ORIGIN_KINDS = [
1338
+ "ci",
1339
+ "ci-rerun",
1340
+ "local",
1341
+ "desktop",
1342
+ "editor",
1343
+ "preflight",
1344
+ "bug",
1345
+ "flake-lab",
1346
+ "probe",
1347
+ "bisect",
1348
+ "reproduce",
1349
+ "import"
1350
+ ];
1351
+ var RUN_ORIGIN_METADATA_KEY = "piwiOrigin";
1352
+ var RUN_ORIGIN_REF_MAX_LENGTH = 200;
1353
+ var RUN_ORIGIN_REF = /^[A-Za-z0-9._:/#@-]+$/;
1354
+ function isRunOriginKind(value) {
1355
+ return typeof value === "string" && RUN_ORIGIN_KINDS.includes(value);
1356
+ }
1357
+ function parseRunOriginRef(value) {
1358
+ if (typeof value !== "string" && typeof value !== "number") return void 0;
1359
+ const ref = String(value).trim();
1360
+ return ref && ref.length <= RUN_ORIGIN_REF_MAX_LENGTH && RUN_ORIGIN_REF.test(ref) ? ref : void 0;
1361
+ }
1362
+
1363
+ // src/internal/collect/metadata-collector.ts
1325
1364
  var DETACHED_HEAD = "HEAD";
1326
1365
  function resolveScmBranch(env, gitBranch) {
1327
1366
  const pick = (...names) => {
@@ -1366,6 +1405,19 @@ function resolveScmBaseBranch(env) {
1366
1405
  const trimmed = target?.trim();
1367
1406
  return trimmed ? normalizeRef(trimmed) : void 0;
1368
1407
  }
1408
+ function detectCi(env) {
1409
+ return Boolean(
1410
+ env.JENKINS_URL || env.GITHUB_ACTIONS || env.GITLAB_CI || env.CIRCLECI || env.TRAVIS || env.TF_BUILD || env.BITBUCKET_BUILD_NUMBER || env.CI && env.CI !== "false" && env.CI !== "0"
1411
+ );
1412
+ }
1413
+ function resolveRunOrigin(env, warn) {
1414
+ const raw = env[PIWI_ORIGIN_ENV.kind]?.trim();
1415
+ let kind = detectCi(env) ? "ci" : "local";
1416
+ if (raw && isRunOriginKind(raw)) kind = raw;
1417
+ else if (raw) warn?.(`Ignoring ${PIWI_ORIGIN_ENV.kind}=${raw}: not a known run origin, recording "${kind}"`);
1418
+ const ref = parseRunOriginRef(env[PIWI_ORIGIN_ENV.ref]);
1419
+ return ref === void 0 ? { kind } : { kind, ref };
1420
+ }
1369
1421
  function normalizeRef(ref) {
1370
1422
  return ref.replace(/^refs\/heads\//, "");
1371
1423
  }
@@ -1375,7 +1427,10 @@ var MetadataCollector = class {
1375
1427
  }
1376
1428
  /** Collect all available metadata from the environment, config, and suite */
1377
1429
  collect(config, suite, options) {
1378
- const metadata = { workingDir: process.cwd() };
1430
+ const metadata = {
1431
+ workingDir: process.cwd(),
1432
+ [RUN_ORIGIN_METADATA_KEY]: resolveRunOrigin(process.env, (message) => this.logger.warn(message))
1433
+ };
1379
1434
  if (options.projectDescription) metadata.projectDescription = options.projectDescription;
1380
1435
  if (options.relatedIssue) metadata.relatedIssue = options.relatedIssue;
1381
1436
  if (options.ciInfo) metadata.ciInfo = options.ciInfo;
@@ -1552,6 +1607,15 @@ var MetadataCollector = class {
1552
1607
  ci.buildUrl = `${env.SYSTEM_TEAMFOUNDATIONSERVERURI}${env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${env.BUILD_BUILDID}`;
1553
1608
  }
1554
1609
  ci.jobName = env.AGENT_JOBNAME;
1610
+ } else if (env.BITBUCKET_BUILD_NUMBER) {
1611
+ ci.provider = "Bitbucket Pipelines";
1612
+ ci.buildNumber = env.BITBUCKET_BUILD_NUMBER;
1613
+ ci.pipelineId = env.BITBUCKET_PIPELINE_UUID;
1614
+ ci.jobId = env.BITBUCKET_STEP_UUID;
1615
+ ci.repository = env.BITBUCKET_REPO_FULL_NAME;
1616
+ if (env.BITBUCKET_REPO_FULL_NAME) {
1617
+ ci.buildUrl = `https://bitbucket.org/${env.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${env.BITBUCKET_BUILD_NUMBER}`;
1618
+ }
1555
1619
  } else if (env.CI) {
1556
1620
  ci.provider = "Unknown CI";
1557
1621
  ci.detected = true;
@@ -3700,6 +3764,131 @@ function isDueForAriaSample(testInfo) {
3700
3764
  return set.has(ariaSampleIdentity(relativeTestFile(testInfo.file), testInfo.title));
3701
3765
  }
3702
3766
 
3767
+ // src/internal/support/base-url-check.ts
3768
+ var BASE_URL_CHECK_ATTEMPTS = 3;
3769
+ var BASE_URL_CHECK_TIMEOUT_MS = 1e4;
3770
+ var BASE_URL_CHECK_DELAY_MS = 2e3;
3771
+ var GATEWAY_ANSWERS = {
3772
+ 502: "bad gateway",
3773
+ 503: "service unavailable",
3774
+ 504: "gateway timeout"
3775
+ };
3776
+ function cliProjectNames(argv = process.argv) {
3777
+ let names = null;
3778
+ for (let i = 0; i < argv.length; i++) {
3779
+ const arg = argv[i];
3780
+ if (arg.startsWith("--project=")) {
3781
+ (names ??= []).push(arg.slice("--project=".length));
3782
+ } else if (arg === "--project") {
3783
+ names ??= [];
3784
+ while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) names.push(argv[++i]);
3785
+ }
3786
+ }
3787
+ return names;
3788
+ }
3789
+ function wildcard(pattern) {
3790
+ const escaped = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
3791
+ return new RegExp(`^${escaped.join(".*")}$`, "i");
3792
+ }
3793
+ function selectedProjects(projects, argv = process.argv) {
3794
+ const names = cliProjectNames(argv);
3795
+ if (!names || names.length === 0) return projects;
3796
+ const patterns = names.map(wildcard);
3797
+ const chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
3798
+ if (!argv.includes("--no-deps")) {
3799
+ const byName = new Map(projects.map((p) => [p.name ?? "", p]));
3800
+ const queue = [...chosen];
3801
+ while (queue.length > 0) {
3802
+ for (const dep of queue.shift().dependencies ?? []) {
3803
+ const project = byName.get(dep);
3804
+ if (project && !chosen.has(project)) {
3805
+ chosen.add(project);
3806
+ queue.push(project);
3807
+ }
3808
+ }
3809
+ }
3810
+ }
3811
+ return projects.filter((p) => chosen.has(p));
3812
+ }
3813
+ function asProxy(value) {
3814
+ const proxy = value;
3815
+ return proxy && typeof proxy === "object" && typeof proxy.server === "string" ? proxy : void 0;
3816
+ }
3817
+ function baseUrlTargets(config, argv = process.argv) {
3818
+ const projects = config.projects?.length ? config.projects : [{ name: "", use: config.use }];
3819
+ const targets = /* @__PURE__ */ new Map();
3820
+ for (const project of selectedProjects(projects, argv)) {
3821
+ const url = project.use?.baseURL;
3822
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) continue;
3823
+ const target = targets.get(url);
3824
+ if (target) {
3825
+ if (project.name) target.projects.push(project.name);
3826
+ continue;
3827
+ }
3828
+ targets.set(url, {
3829
+ url,
3830
+ projects: project.name ? [project.name] : [],
3831
+ ignoreHTTPSErrors: project.use?.ignoreHTTPSErrors === true,
3832
+ proxy: asProxy(project.use?.proxy)
3833
+ });
3834
+ }
3835
+ return [...targets.values()];
3836
+ }
3837
+ function requestError(error) {
3838
+ const first = errorMessage(error).split("\n")[0].trim();
3839
+ return first.replace(/^apiRequestContext\.\w+:\s*/, "");
3840
+ }
3841
+ var playwrightProbe = async (target, timeoutMs) => {
3842
+ const { request } = await import("@playwright/test");
3843
+ const context = await request.newContext({ ignoreHTTPSErrors: target.ignoreHTTPSErrors, proxy: target.proxy });
3844
+ try {
3845
+ const response = await context.get(target.url, { timeout: timeoutMs, maxRedirects: 0, failOnStatusCode: false });
3846
+ return { status: response.status() };
3847
+ } catch (error) {
3848
+ return { error: requestError(error) };
3849
+ } finally {
3850
+ await context.dispose().catch(() => {
3851
+ });
3852
+ }
3853
+ };
3854
+ async function checkBaseUrl(target, options = {}) {
3855
+ const attempts = Math.max(1, options.attempts ?? BASE_URL_CHECK_ATTEMPTS);
3856
+ const timeoutMs = options.timeoutMs ?? BASE_URL_CHECK_TIMEOUT_MS;
3857
+ const delayMs = options.delayMs ?? BASE_URL_CHECK_DELAY_MS;
3858
+ const probe = options.probe ?? playwrightProbe;
3859
+ let reason = "";
3860
+ for (let attempt = 1; attempt <= attempts; attempt++) {
3861
+ const outcome = await probe(target, timeoutMs);
3862
+ if ("status" in outcome) {
3863
+ const gateway = GATEWAY_ANSWERS[outcome.status];
3864
+ if (!gateway) return null;
3865
+ reason = `${outcome.status} ${gateway}`;
3866
+ } else {
3867
+ reason = outcome.error;
3868
+ }
3869
+ if (attempt < attempts) await new Promise((resolve7) => setTimeout(resolve7, delayMs));
3870
+ }
3871
+ return `${reason} (${attempts} ${attempts === 1 ? "attempt" : "attempts"})`;
3872
+ }
3873
+ async function checkBaseUrls(config, options = {}) {
3874
+ const targets = baseUrlTargets(config, options.argv);
3875
+ const reasons = await Promise.all(targets.map((target) => checkBaseUrl(target, options)));
3876
+ const down = targets.flatMap((target, i) => reasons[i] ? [{ target, reason: reasons[i] }] : []);
3877
+ if (down.length === 0) return targets;
3878
+ const lines = down.map(({ target, reason }) => {
3879
+ const used = target.projects.length > 0 ? ` (${target.projects.join(", ")})` : "";
3880
+ return ` ${target.url}${used}: ${reason}`;
3881
+ });
3882
+ const message = [
3883
+ `[Piwi Dashboard] The app under test did not answer, so no test ran:`,
3884
+ ...lines,
3885
+ `Bring the environment back and run again, or set checkBaseUrl: false (PIWI_CHECK_BASE_URL=false) to run the tests anyway.`
3886
+ ].join("\n");
3887
+ const error = new Error(message);
3888
+ error.stack = message;
3889
+ throw error;
3890
+ }
3891
+
3703
3892
  // src/internal/manifest/collect.ts
3704
3893
  var fs12 = __toESM(require("fs"));
3705
3894
  var path11 = __toESM(require("path"));
@@ -3799,6 +3988,10 @@ function createGlobalSetup(options, userSetup) {
3799
3988
  if (userSetup) return userSetup(config);
3800
3989
  return;
3801
3990
  }
3991
+ if (opts.checkBaseUrl === true) {
3992
+ const checked = await checkBaseUrls(config ?? {});
3993
+ for (const target of checked) logger.debug(`Base URL answered: ${target.url}`);
3994
+ }
3802
3995
  if (opts.enabled === false || !opts.serverUrl) {
3803
3996
  logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
3804
3997
  if (userSetup) return userSetup(config);
@@ -3906,7 +4099,7 @@ var path13 = __toESM(require("path"));
3906
4099
 
3907
4100
  // src/internal/probe/plan.ts
3908
4101
  function probeRunMetadata(base = {}) {
3909
- return { ...base, piwiProbe: true };
4102
+ return { ...base, piwiProbe: true, [RUN_ORIGIN_METADATA_KEY]: { kind: "probe" } };
3910
4103
  }
3911
4104
  function parseRouteKey(routeKey2) {
3912
4105
  const space = routeKey2.indexOf(" ");
@@ -4515,7 +4708,12 @@ function loadFlakePlan() {
4515
4708
  }
4516
4709
  }
4517
4710
  function flakeRunMetadata(stamp, base = {}) {
4518
- return { ...base, [FLAKE_LAB_RUN_METADATA_KEY]: { experimentId: stamp.experimentId, armId: stamp.armId } };
4711
+ const ref = parseRunOriginRef(stamp.experimentId);
4712
+ return {
4713
+ ...base,
4714
+ [FLAKE_LAB_RUN_METADATA_KEY]: { experimentId: stamp.experimentId, armId: stamp.armId },
4715
+ [RUN_ORIGIN_METADATA_KEY]: ref === void 0 ? { kind: "flake-lab" } : { kind: "flake-lab", ref }
4716
+ };
4519
4717
  }
4520
4718
  function relativeTestFile2(file) {
4521
4719
  return file ? path14.relative(process.cwd(), file).split(path14.sep).join("/") : null;
@@ -5099,6 +5297,9 @@ function ciBuildUrlFromMetadata(metadata) {
5099
5297
 
5100
5298
  // src/internal/submit/run-submitter.ts
5101
5299
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
5300
+ function markSubmitFallback(run, path31, reason) {
5301
+ run.metadata.ingestHealth = { submitFallback: { path: path31, reason } };
5302
+ }
5102
5303
  var RunSubmitter = class {
5103
5304
  /**
5104
5305
  * @param httpClient HTTP client for auth resolution and the finish call.
@@ -5174,13 +5375,21 @@ var RunSubmitter = class {
5174
5375
  }
5175
5376
  if (!sm) await this.recovery.tryUpload(this.httpClient, auth);
5176
5377
  let outcome = { done: false, output: null };
5177
- if (sm?.enabled && sm?.runId != null && !sm.lostResults) {
5178
- outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
5378
+ let fallbackReason = null;
5379
+ if (sm?.enabled && sm?.runId != null) {
5380
+ if (sm.lostResults) fallbackReason = "results-lost";
5381
+ else {
5382
+ outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
5383
+ if (!outcome.done) fallbackReason = "finish-failed";
5384
+ }
5179
5385
  }
5180
5386
  if (!outcome.done && !this.httpClient.closed && (this.hasReports(run) || run.options.uploadTraces)) {
5387
+ if (fallbackReason) markSubmitFallback(run, "upload", fallbackReason);
5181
5388
  outcome = await this.tryUploadWithFiles(run, overallStatus, duration, auth);
5389
+ if (!outcome.done) fallbackReason = "upload-failed";
5182
5390
  }
5183
5391
  if (!outcome.done) {
5392
+ if (fallbackReason) markSubmitFallback(run, "submit", fallbackReason);
5184
5393
  outcome = await this.tryUploadJSON(run, overallStatus, duration, auth);
5185
5394
  }
5186
5395
  if (outcome.output) {
@@ -5490,6 +5699,35 @@ function parsePsOutput(text) {
5490
5699
  }
5491
5700
  return out;
5492
5701
  }
5702
+ function parseWindowsProcesses(text) {
5703
+ let data;
5704
+ try {
5705
+ data = JSON.parse(text.replace(/^\uFEFF/, ""));
5706
+ } catch {
5707
+ return [];
5708
+ }
5709
+ const rows = Array.isArray(data) ? data : [data];
5710
+ const out = [];
5711
+ for (const row of rows) {
5712
+ if (typeof row !== "object" || row === null) continue;
5713
+ const record = row;
5714
+ const pid = Number(record.ProcessId);
5715
+ const ppid = Number(record.ParentProcessId);
5716
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
5717
+ const workingSet = Number(record.WorkingSetSize);
5718
+ const user = Number(record.UserModeTime);
5719
+ const kernel = Number(record.KernelModeTime);
5720
+ out.push({
5721
+ pid,
5722
+ ppid,
5723
+ rssKb: Number.isFinite(workingSet) ? Math.round(workingSet / 1024) : 0,
5724
+ cpuMs: ((Number.isFinite(user) ? user : 0) + (Number.isFinite(kernel) ? kernel : 0)) / 1e4,
5725
+ name: typeof record.Name === "string" ? record.Name : "",
5726
+ commandLine: typeof record.CommandLine === "string" ? record.CommandLine : ""
5727
+ });
5728
+ }
5729
+ return out;
5730
+ }
5493
5731
  function roleOf(comm, cmdline) {
5494
5732
  const command = `${comm} ${cmdline}`;
5495
5733
  if (/workerProcessEntry/.test(cmdline)) return "worker";
@@ -6813,10 +7051,27 @@ var path22 = __toESM(require("path"));
6813
7051
  var import_node_child_process2 = require("child_process");
6814
7052
  var WALK_FILE_CAP = 1e4;
6815
7053
  var SERIES_CAP = 3600;
7054
+ var TIMED_CAP = 3600;
6816
7055
  var PLAYWRIGHT_TEMP = /^(playwright_\w+dev_profile-|playwright-artifacts-)/;
6817
7056
  function round12(value) {
6818
7057
  return Math.round(value * 10) / 10;
6819
7058
  }
7059
+ function mean(a, b) {
7060
+ return round12((a + b) / 2);
7061
+ }
7062
+ function mean2(a, b) {
7063
+ return Math.round((a + b) / 2 * 100) / 100;
7064
+ }
7065
+ function pushTimed(points, point, merge) {
7066
+ points.push(point);
7067
+ if (points.length <= TIMED_CAP) return points;
7068
+ const merged = [];
7069
+ for (let i = 0; i < points.length; i += 2) {
7070
+ const next = points[i + 1];
7071
+ merged.push(next ? [points[i][0], merge(points[i][1], next[1])] : points[i]);
7072
+ }
7073
+ return merged;
7074
+ }
6820
7075
  function pct(part, whole) {
6821
7076
  return whole > 0 ? round12(part / whole * 100) : null;
6822
7077
  }
@@ -6841,6 +7096,21 @@ function defaultPs() {
6841
7096
  );
6842
7097
  });
6843
7098
  }
7099
+ function defaultWindowsQuery() {
7100
+ return new Promise((resolve7, reject) => {
7101
+ (0, import_node_child_process2.execFile)(
7102
+ "powershell.exe",
7103
+ [
7104
+ "-NoProfile",
7105
+ "-NonInteractive",
7106
+ "-Command",
7107
+ "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.ParentProcessId -ne $PID } | Select-Object ProcessId,ParentProcessId,WorkingSetSize,UserModeTime,KernelModeTime,Name,CommandLine | ConvertTo-Json -Compress"
7108
+ ],
7109
+ { timeout: 1e4, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
7110
+ (error, stdout) => error ? reject(error) : resolve7(stdout)
7111
+ );
7112
+ });
7113
+ }
6844
7114
  function freeBytes(dir) {
6845
7115
  let current = path22.resolve(dir);
6846
7116
  for (; ; ) {
@@ -6888,6 +7158,12 @@ var RunSampler = class {
6888
7158
  this.cpuStart = null;
6889
7159
  this.cpuPrevious = null;
6890
7160
  this.series = [];
7161
+ this.cpuPoints = [];
7162
+ this.memoryPoints = [];
7163
+ /** Each worker process's tree, by the worker's process key. */
7164
+ this.workerTrees = /* @__PURE__ */ new Map();
7165
+ /** When the workers' CPU was last read, for the cores they used since. */
7166
+ this.workersReadAt = null;
6891
7167
  this.pressureStart = { cpu: null, memory: null };
6892
7168
  this.containerStart = null;
6893
7169
  this.memoryKind = null;
@@ -6907,8 +7183,10 @@ var RunSampler = class {
6907
7183
  this.tmpDir = options.tmpDir ?? os8.tmpdir();
6908
7184
  this.intervalMs = options.intervalMs ?? 1e3;
6909
7185
  this.heavyEvery = Math.max(1, options.heavyEvery ?? 5);
7186
+ this.windowsTreeEvery = Math.max(1, options.windowsTreeEvery ?? 3);
6910
7187
  this.now = options.now ?? Date.now;
6911
7188
  this.runPs = options.runPs ?? defaultPs;
7189
+ this.runWindows = options.runWindows ?? defaultWindowsQuery;
6912
7190
  this.linux = this.platform === "linux" && this.proc.available();
6913
7191
  }
6914
7192
  /** Take the first sample and sample on a timer that never keeps the process alive. */
@@ -6931,11 +7209,24 @@ var RunSampler = class {
6931
7209
  this.timer = null;
6932
7210
  try {
6933
7211
  await this.running;
6934
- await this.sample(true);
7212
+ await this.sample(true, true);
6935
7213
  } catch {
6936
7214
  }
6937
7215
  return this.profile(await this.leftoverBytes());
6938
7216
  }
7217
+ /** The series read so far, each point at the time it was read. */
7218
+ timedSeries() {
7219
+ return {
7220
+ startedAt: this.startedAt,
7221
+ cpuPct: [...this.cpuPoints],
7222
+ memoryBytes: [...this.memoryPoints],
7223
+ workers: [...this.workerTrees.values()].map((tree) => ({
7224
+ pid: tree.pid,
7225
+ cpuCores: [...tree.cpuCores],
7226
+ memoryBytes: [...tree.memoryBytes]
7227
+ }))
7228
+ };
7229
+ }
6939
7230
  schedule() {
6940
7231
  if (this.running) return;
6941
7232
  const heavy = this.samples % this.heavyEvery === 0;
@@ -6946,18 +7237,22 @@ var RunSampler = class {
6946
7237
  cpuTicks() {
6947
7238
  return this.linux ? this.proc.cpuTicks() : nodeCpuTicks();
6948
7239
  }
6949
- async sample(heavy) {
7240
+ async sample(heavy, forceTree = false) {
7241
+ const treeDue = forceTree || this.samples % (this.heavyEvery * this.windowsTreeEvery) === 0;
6950
7242
  this.samples++;
7243
+ const at = this.now() - this.startedAt;
6951
7244
  const ticks = this.cpuTicks();
6952
7245
  if (ticks && this.cpuPrevious) {
6953
7246
  const busy = pct(ticks.busy - this.cpuPrevious.busy, ticks.total - this.cpuPrevious.total);
6954
7247
  if (busy !== null && this.series.length < SERIES_CAP) this.series.push(busy);
7248
+ if (busy !== null) this.cpuPoints = pushTimed(this.cpuPoints, [at, busy], mean);
6955
7249
  }
6956
7250
  if (ticks) this.cpuPrevious = ticks;
6957
7251
  const availableKb = this.linux ? this.proc.memAvailableKb() : Math.round(os8.freemem() / 1024);
6958
7252
  if (availableKb !== null) this.lowestAvailableKb = Math.min(this.lowestAvailableKb ?? Infinity, availableKb);
6959
- if (this.linux) await this.sampleLinuxTree(heavy);
6960
- else if (this.platform === "darwin" && heavy) await this.samplePsTree();
7253
+ if (this.linux) await this.sampleLinuxTree(heavy, at);
7254
+ else if (this.platform === "darwin" && heavy) await this.samplePsTree(at);
7255
+ else if (this.platform === "win32" && treeDue) await this.sampleWindowsTree(at);
6961
7256
  if (heavy) await this.sampleDisk();
6962
7257
  }
6963
7258
  track(key, role, cpuMs, waitMs) {
@@ -6977,6 +7272,49 @@ var RunSampler = class {
6977
7272
  lastWaitMs: waitMs
6978
7273
  });
6979
7274
  }
7275
+ /**
7276
+ * The worker each process of the tree belongs to: a worker process itself,
7277
+ * or the worker it descends from (its browsers and their children). The
7278
+ * runner and the web server belong to none. The tree lists parents first.
7279
+ */
7280
+ workersOf(tree, roles, keyOf2) {
7281
+ const out = /* @__PURE__ */ new Map();
7282
+ for (const proc of tree) {
7283
+ if (roles.get(proc.pid) === "worker") out.set(proc.pid, { key: keyOf2(proc), pid: proc.pid });
7284
+ else {
7285
+ const parent = out.get(proc.ppid);
7286
+ if (parent) out.set(proc.pid, parent);
7287
+ }
7288
+ }
7289
+ return out;
7290
+ }
7291
+ /** The CPU a process used since the previous sample: all of it for one born since, none at the first sample. */
7292
+ cpuSince(key, cpuMs) {
7293
+ const known = this.tracked.get(key);
7294
+ if (known) return Math.max(0, cpuMs - known.lastCpuMs);
7295
+ return this.samples === 1 ? 0 : cpuMs;
7296
+ }
7297
+ /**
7298
+ * Add a reading to each worker's tree: the cores its processes used since
7299
+ * the previous reading, and on heavy samples their memory.
7300
+ */
7301
+ noteWorkers(workers, cpuMsByWorker, bytesByWorker, at) {
7302
+ const elapsed = this.workersReadAt === null ? 0 : at - this.workersReadAt;
7303
+ for (const { key, pid } of new Map([...workers.values()].map((w) => [w.key, w])).values()) {
7304
+ let tree = this.workerTrees.get(key);
7305
+ if (!tree) {
7306
+ tree = { pid, cpuCores: [], memoryBytes: [] };
7307
+ this.workerTrees.set(key, tree);
7308
+ }
7309
+ if (elapsed > 0) {
7310
+ const cores = Math.round((cpuMsByWorker.get(key) ?? 0) / elapsed * 100) / 100;
7311
+ tree.cpuCores = pushTimed(tree.cpuCores, [at, cores], mean2);
7312
+ }
7313
+ const bytes = bytesByWorker?.get(key);
7314
+ if (bytes !== void 0) tree.memoryBytes = pushTimed(tree.memoryBytes, [at, bytes], Math.max);
7315
+ }
7316
+ this.workersReadAt = at;
7317
+ }
6980
7318
  /** The role of each process of the tree, a child of the runner being the web server unless it says otherwise. */
6981
7319
  resolveRoles(tree, explicit) {
6982
7320
  const roles = /* @__PURE__ */ new Map([[this.rootPid, "runner"]]);
@@ -6987,7 +7325,7 @@ var RunSampler = class {
6987
7325
  }
6988
7326
  return roles;
6989
7327
  }
6990
- async sampleLinuxTree(heavy) {
7328
+ async sampleLinuxTree(heavy, at) {
6991
7329
  const table = this.proc.table();
6992
7330
  const root = table.get(this.rootPid);
6993
7331
  if (!root) return;
@@ -6998,12 +7336,22 @@ var RunSampler = class {
6998
7336
  const known = this.tracked.get(key)?.role;
6999
7337
  return known && !heavy ? known : roleOf(proc.comm, this.proc.cmdline(proc.pid));
7000
7338
  });
7339
+ const workers = this.workersOf(tree, roles, (proc) => `${proc.pid}:${proc.start}`);
7340
+ const cpuMsByWorker = /* @__PURE__ */ new Map();
7001
7341
  for (const proc of tree) {
7342
+ const key = `${proc.pid}:${proc.start}`;
7343
+ const worker = workers.get(proc.pid);
7344
+ if (worker) {
7345
+ cpuMsByWorker.set(worker.key, (cpuMsByWorker.get(worker.key) ?? 0) + this.cpuSince(key, proc.cpuMs));
7346
+ }
7002
7347
  const waitMs = this.proc.runWaitMs(proc.pid);
7003
7348
  if (waitMs !== null) this.waitMeasured = true;
7004
- this.track(`${proc.pid}:${proc.start}`, roles.get(proc.pid) ?? "other", proc.cpuMs, waitMs);
7349
+ this.track(key, roles.get(proc.pid) ?? "other", proc.cpuMs, waitMs);
7350
+ }
7351
+ if (!heavy) {
7352
+ this.noteWorkers(workers, cpuMsByWorker, null, at);
7353
+ return;
7005
7354
  }
7006
- if (!heavy) return;
7007
7355
  let total = 0;
7008
7356
  let largest = null;
7009
7357
  const memory = await Promise.all(
@@ -7013,42 +7361,67 @@ var RunSampler = class {
7013
7361
  return { proc, kb: this.proc.statusKb(proc.pid, "VmRSS"), fallback: true };
7014
7362
  })
7015
7363
  );
7364
+ const bytesByWorker = /* @__PURE__ */ new Map();
7016
7365
  for (const { proc, kb, fallback } of memory) {
7017
7366
  if (kb === null) continue;
7018
7367
  if (fallback) this.rssFallbackPids.add(`${proc.pid}:${proc.start}`);
7019
7368
  total += kb * 1024;
7020
7369
  if (!largest || kb * 1024 > largest.bytes) largest = { role: roles.get(proc.pid) ?? "other", bytes: kb * 1024 };
7370
+ const worker = workers.get(proc.pid);
7371
+ if (worker) bytesByWorker.set(worker.key, (bytesByWorker.get(worker.key) ?? 0) + kb * 1024);
7021
7372
  }
7022
7373
  this.memoryKind = memory.some((m) => m.kb !== null && !m.fallback) ? "pss" : memory.length ? "rss" : null;
7023
- this.notePeak(total, largest);
7374
+ this.notePeak(total, largest, at);
7375
+ this.noteWorkers(workers, cpuMsByWorker, bytesByWorker, at);
7024
7376
  }
7025
- async samplePsTree() {
7377
+ async samplePsTree(at) {
7026
7378
  let list;
7027
7379
  try {
7028
7380
  list = parsePsOutput(await this.runPs());
7029
7381
  } catch {
7030
7382
  return;
7031
7383
  }
7384
+ this.noteTree(list, (proc) => roleOf(path22.basename(proc.command.split(" ")[0] ?? ""), proc.command), at);
7385
+ }
7386
+ async sampleWindowsTree(at) {
7387
+ let list;
7388
+ try {
7389
+ list = parseWindowsProcesses(await this.runWindows());
7390
+ } catch {
7391
+ return;
7392
+ }
7393
+ this.noteTree(list, (proc) => roleOf(proc.name, proc.commandLine), at);
7394
+ }
7395
+ /** One reading of the run's tree: each process's role, CPU time and memory, and each worker's share of both. */
7396
+ noteTree(list, role, at) {
7032
7397
  const root = list.find((proc) => proc.pid === this.rootPid);
7033
7398
  if (!root) return;
7034
7399
  this.treeMeasured = true;
7035
7400
  const tree = [root, ...descendantsOf(this.rootPid, list)];
7036
- const roles = this.resolveRoles(
7037
- tree.slice(1),
7038
- (proc) => roleOf(path22.basename(proc.command.split(" ")[0] ?? ""), proc.command)
7039
- );
7401
+ const roles = this.resolveRoles(tree.slice(1), role);
7040
7402
  let total = 0;
7041
7403
  let largest = null;
7404
+ const workers = this.workersOf(tree, roles, (proc) => String(proc.pid));
7405
+ const cpuMsByWorker = /* @__PURE__ */ new Map();
7406
+ const bytesByWorker = /* @__PURE__ */ new Map();
7042
7407
  for (const proc of tree) {
7043
- const role = roles.get(proc.pid) ?? "other";
7044
- this.track(String(proc.pid), role, proc.cpuMs, null);
7408
+ const procRole = roles.get(proc.pid) ?? "other";
7409
+ const worker = workers.get(proc.pid);
7410
+ if (worker) {
7411
+ const cpuMs = this.cpuSince(String(proc.pid), proc.cpuMs);
7412
+ cpuMsByWorker.set(worker.key, (cpuMsByWorker.get(worker.key) ?? 0) + cpuMs);
7413
+ bytesByWorker.set(worker.key, (bytesByWorker.get(worker.key) ?? 0) + proc.rssKb * 1024);
7414
+ }
7415
+ this.track(String(proc.pid), procRole, proc.cpuMs, null);
7045
7416
  total += proc.rssKb * 1024;
7046
- if (!largest || proc.rssKb * 1024 > largest.bytes) largest = { role, bytes: proc.rssKb * 1024 };
7417
+ if (!largest || proc.rssKb * 1024 > largest.bytes) largest = { role: procRole, bytes: proc.rssKb * 1024 };
7047
7418
  }
7048
7419
  this.memoryKind = "rss";
7049
- this.notePeak(total, largest);
7420
+ this.notePeak(total, largest, at);
7421
+ this.noteWorkers(workers, cpuMsByWorker, bytesByWorker, at);
7050
7422
  }
7051
- notePeak(total, largest) {
7423
+ notePeak(total, largest, at) {
7424
+ this.memoryPoints = pushTimed(this.memoryPoints, [at, total], Math.max);
7052
7425
  if (this.peakBytes === null || total > this.peakBytes) {
7053
7426
  this.peakBytes = total;
7054
7427
  this.peakAtMs = this.now() - this.startedAt;
@@ -7252,8 +7625,8 @@ function sparkline(series, width = 24) {
7252
7625
  Math.floor(i * series.length / buckets),
7253
7626
  Math.floor((i + 1) * series.length / buckets)
7254
7627
  );
7255
- const mean = slice.reduce((sum, v) => sum + v, 0) / Math.max(1, slice.length);
7256
- out += BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor(mean / 100 * BARS.length)))];
7628
+ const mean3 = slice.reduce((sum, v) => sum + v, 0) / Math.max(1, slice.length);
7629
+ out += BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor(mean3 / 100 * BARS.length)))];
7257
7630
  }
7258
7631
  return out;
7259
7632
  }
@@ -7357,6 +7730,9 @@ function formatMachinePanel(profile, extras) {
7357
7730
  var REPORT_FINDINGS_CAP = 100;
7358
7731
  var REPORT_SERIES_CAP = 240;
7359
7732
  var REPORT_WORKER_POINTS_CAP = 200;
7733
+ var TIMELINE_SERIES_CAP = 600;
7734
+ var TIMELINE_WORKER_POINTS_CAP = 300;
7735
+ var TIMELINE_WORKERS_BUDGET = 6e3;
7360
7736
  function downsample(series, cap) {
7361
7737
  if (series.length <= cap) return series;
7362
7738
  const out = [];
@@ -7366,6 +7742,18 @@ function downsample(series, cap) {
7366
7742
  }
7367
7743
  return out;
7368
7744
  }
7745
+ function downsamplePoints(points, cap, merge) {
7746
+ if (points.length <= cap) return points;
7747
+ const out = [];
7748
+ for (let i = 0; i < cap; i++) {
7749
+ const slice = points.slice(Math.floor(i * points.length / cap), Math.floor((i + 1) * points.length / cap));
7750
+ if (slice.length === 0) continue;
7751
+ const values = slice.map((point) => point[1]);
7752
+ const value = merge === "max" ? Math.max(...values) : Math.round(values.reduce((sum, v) => sum + v, 0) / values.length * 10) / 10;
7753
+ out.push([slice[0][0], value]);
7754
+ }
7755
+ return out;
7756
+ }
7369
7757
  function nonZero(bytes) {
7370
7758
  const out = Object.fromEntries(Object.entries(bytes).filter(([, n]) => (n ?? 0) > 0));
7371
7759
  return Object.keys(out).length > 0 ? out : null;
@@ -7405,6 +7793,93 @@ function openPagesByWorker(censuses) {
7405
7793
  }
7406
7794
  return out;
7407
7795
  }
7796
+ function pagesOverTime(censuses) {
7797
+ const byWorker = /* @__PURE__ */ new Map();
7798
+ for (const census of censuses) {
7799
+ const list = byWorker.get(census.worker) ?? [];
7800
+ list.push(census);
7801
+ byWorker.set(census.worker, list);
7802
+ }
7803
+ const out = [];
7804
+ for (const [worker, list] of [...byWorker.entries()].sort((a, b) => a[0] - b[0])) {
7805
+ const pages = /* @__PURE__ */ new Set();
7806
+ const changes = [];
7807
+ let lastAt = -Infinity;
7808
+ for (const census of list) {
7809
+ lastAt = Math.max(lastAt, census.at);
7810
+ for (const birth of census.born) {
7811
+ if (birth.kind !== "page") continue;
7812
+ pages.add(birth.id);
7813
+ changes.push([birth.at, 1]);
7814
+ }
7815
+ }
7816
+ for (const census of list) {
7817
+ for (const close of census.closed) if (pages.has(close.id)) changes.push([close.at, -1]);
7818
+ }
7819
+ if (changes.length === 0) continue;
7820
+ changes.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
7821
+ const events = [];
7822
+ let open = 0;
7823
+ for (const [at, delta] of changes) {
7824
+ open = Math.max(0, open + delta);
7825
+ const last = events[events.length - 1];
7826
+ if (last && last[0] === at) last[1] = open;
7827
+ else events.push([at, open]);
7828
+ }
7829
+ if (open > 0 && lastAt >= events[events.length - 1][0]) events.push([lastAt, 0]);
7830
+ out.push({ worker, events });
7831
+ }
7832
+ return out;
7833
+ }
7834
+ function workerTreesByIndex(samples, censuses) {
7835
+ const byPid = /* @__PURE__ */ new Map();
7836
+ for (const census of censuses) {
7837
+ const list = byPid.get(census.pid) ?? [];
7838
+ list.push({ worker: census.worker, at: census.at });
7839
+ byPid.set(census.pid, list);
7840
+ }
7841
+ const out = /* @__PURE__ */ new Map();
7842
+ for (const tree of samples.workers) {
7843
+ const candidates = byPid.get(tree.pid);
7844
+ const times = [...tree.cpuCores, ...tree.memoryBytes].map(([at]) => samples.startedAt + at);
7845
+ if (!candidates || times.length === 0) continue;
7846
+ const from = Math.min(...times);
7847
+ const to = Math.max(...times);
7848
+ const distance = (at) => at < from ? from - at : at > to ? at - to : 0;
7849
+ const nearest = candidates.reduce((best, c) => distance(c.at) < distance(best.at) ? c : best);
7850
+ if (!out.has(nearest.worker)) out.set(nearest.worker, { worker: nearest.worker, ...tree });
7851
+ }
7852
+ return [...out.values()].sort((a, b) => a.worker - b.worker);
7853
+ }
7854
+ function resourceTimeline(samples, censuses) {
7855
+ const workers = pagesOverTime(censuses);
7856
+ const hasSamples = samples !== null && (samples.cpuPct.length > 0 || samples.memoryBytes.length > 0);
7857
+ if (!hasSamples && workers.length === 0) return null;
7858
+ let startedAt = hasSamples ? samples.startedAt : Infinity;
7859
+ for (const { events } of workers) startedAt = Math.min(startedAt, events[0][0]);
7860
+ const shift = hasSamples ? samples.startedAt - startedAt : 0;
7861
+ const relative13 = (points, by) => points.map(([at, value]) => [Math.round(at + by), value]);
7862
+ const trees = hasSamples ? workerTreesByIndex(samples, censuses) : [];
7863
+ const perWorker = Math.max(
7864
+ 60,
7865
+ Math.min(TIMELINE_WORKER_POINTS_CAP, Math.floor(TIMELINE_WORKERS_BUDGET / Math.max(1, 2 * trees.length)))
7866
+ );
7867
+ return {
7868
+ startedAt,
7869
+ cpuPct: hasSamples ? downsamplePoints(relative13(samples.cpuPct, shift), TIMELINE_SERIES_CAP, "mean") : [],
7870
+ memoryBytes: hasSamples ? downsamplePoints(relative13(samples.memoryBytes, shift), TIMELINE_SERIES_CAP, "max") : [],
7871
+ pages: workers.map(({ worker, events }) => {
7872
+ const points = relative13(events, -startedAt);
7873
+ const last = points.pop();
7874
+ return { worker, points: [...downsamplePoints(points, TIMELINE_WORKER_POINTS_CAP - 1, "max"), last] };
7875
+ }),
7876
+ workers: trees.map((tree) => ({
7877
+ worker: tree.worker,
7878
+ cpuCores: downsamplePoints(relative13(tree.cpuCores, shift), perWorker, "mean"),
7879
+ memoryBytes: downsamplePoints(relative13(tree.memoryBytes, shift), perWorker, "max")
7880
+ }))
7881
+ };
7882
+ }
7408
7883
  function resourceReportWire(input) {
7409
7884
  const { report, profile } = input;
7410
7885
  return {
@@ -7415,7 +7890,8 @@ function resourceReportWire(input) {
7415
7890
  profile: profile ? { ...profile, cpu: { ...profile.cpu, series: downsample(profile.cpu.series, REPORT_SERIES_CAP) } } : null,
7416
7891
  workers: openPagesByWorker(input.censuses),
7417
7892
  artifactBytes: nonZero(input.artifacts) ?? {},
7418
- workerHealth: workerHealthOf(input.censuses)
7893
+ workerHealth: workerHealthOf(input.censuses),
7894
+ timeline: resourceTimeline(input.samples ?? null, input.censuses)
7419
7895
  };
7420
7896
  }
7421
7897
 
@@ -8366,6 +8842,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
8366
8842
  async reportResources() {
8367
8843
  if (this.options.captureResources === false) return;
8368
8844
  const profile = this.sampler ? await this.sampler.stop() : null;
8845
+ const samples = this.sampler?.timedSeries() ?? null;
8369
8846
  this.sampler = null;
8370
8847
  const censuses = [...this.resourceCensuses];
8371
8848
  if (this.resourcesFile) {
@@ -8388,6 +8865,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
8388
8865
  this.resourceReport = resourceReportWire({
8389
8866
  report,
8390
8867
  profile,
8868
+ samples,
8391
8869
  censuses,
8392
8870
  artifacts: this.artifactBytes,
8393
8871
  shardIndex: this.shardInfo?.current ?? null
@@ -8448,6 +8926,10 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
8448
8926
  /** Playwright reporter hook: called when the full test run finishes */
8449
8927
  async onEnd(result) {
8450
8928
  if (this.listMode || !this.enabled) return;
8929
+ if (this.startTime === null) {
8930
+ this.logger.debug("The run stopped before any test began \u2014 nothing to report.");
8931
+ return;
8932
+ }
8451
8933
  const unrunReason = resolveUnrunReason(result?.status, {
8452
8934
  maxFailures: this.maxFailures,
8453
8935
  failures: this.failedTests + this.timedOutTests
@@ -8564,6 +9046,8 @@ var import_node_zlib2 = require("zlib");
8564
9046
  // ../picker-dom/src/probe.ts
8565
9047
  function probeElementAttrs(el, arg) {
8566
9048
  const { keep, tagRoles, inputRoles, roleSources, includeStructural } = arg;
9049
+ const countMatches = arg.countMatches !== false;
9050
+ const testIdAttr = arg.testIdAttribute || "data-testid";
8567
9051
  const FIELD_TAGS = ["input", "select", "textarea"];
8568
9052
  const textWithout = (root, skip) => {
8569
9053
  if (root === skip) return "";
@@ -8588,42 +9072,45 @@ function probeElementAttrs(el, arg) {
8588
9072
  }
8589
9073
  };
8590
9074
  const attrMap = {};
8591
- for (const key of keep) {
9075
+ const keys = arg.testIdAttribute && keep.indexOf(arg.testIdAttribute) === -1 ? [...keep, arg.testIdAttribute] : keep;
9076
+ for (const key of keys) {
8592
9077
  const v = el.getAttribute(key) ?? el[key];
8593
9078
  attrMap[key] = typeof v === "string" ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
8594
9079
  }
8595
9080
  const r = el.getBoundingClientRect();
8596
9081
  const selectorCounts = {};
8597
- try {
8598
- const doc = el.ownerDocument;
8599
- const cssEsc = (s) => doc.defaultView.CSS.escape(s);
8600
- const count = (sel) => {
8601
- try {
8602
- return doc.querySelectorAll(sel).length;
8603
- } catch {
8604
- return void 0;
8605
- }
8606
- };
8607
- if (attrMap["data-testid"]) {
8608
- selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap["data-testid"])}]`);
8609
- }
8610
- if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
8611
- if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
8612
- if (attrMap["placeholder"]) {
8613
- selectorCounts.placeholder = count(`[placeholder=${JSON.stringify(attrMap["placeholder"])}]`);
8614
- }
8615
- if (attrMap["alt"]) selectorCounts.alt = count(`[alt=${JSON.stringify(attrMap["alt"])}]`);
8616
- if (attrMap["title"]) selectorCounts.title = count(`[title=${JSON.stringify(attrMap["title"])}]`);
8617
- const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
8618
- if (classList.length > 0) {
8619
- const classCounts = {};
8620
- for (const cls of classList) {
8621
- const n = count(`.${cssEsc(cls)}`);
8622
- if (n !== void 0) classCounts[cls] = n;
9082
+ if (countMatches) {
9083
+ try {
9084
+ const doc = el.ownerDocument;
9085
+ const cssEsc = (s) => doc.defaultView.CSS.escape(s);
9086
+ const count = (sel) => {
9087
+ try {
9088
+ return doc.querySelectorAll(sel).length;
9089
+ } catch {
9090
+ return void 0;
9091
+ }
9092
+ };
9093
+ if (attrMap[testIdAttr]) {
9094
+ selectorCounts.testId = count(`[${testIdAttr}=${JSON.stringify(attrMap[testIdAttr])}]`);
9095
+ }
9096
+ if (attrMap["id"]) selectorCounts.id = count(`#${cssEsc(attrMap["id"])}`);
9097
+ if (attrMap["name"]) selectorCounts.name = count(`[name=${JSON.stringify(attrMap["name"])}]`);
9098
+ if (attrMap["placeholder"]) {
9099
+ selectorCounts.placeholder = count(`[placeholder=${JSON.stringify(attrMap["placeholder"])}]`);
9100
+ }
9101
+ if (attrMap["alt"]) selectorCounts.alt = count(`[alt=${JSON.stringify(attrMap["alt"])}]`);
9102
+ if (attrMap["title"]) selectorCounts.title = count(`[title=${JSON.stringify(attrMap["title"])}]`);
9103
+ const classList = (attrMap["class"] || "").split(/\s+/).filter((c) => c.length > 1).slice(0, 10);
9104
+ if (classList.length > 0) {
9105
+ const classCounts = {};
9106
+ for (const cls of classList) {
9107
+ const n = count(`.${cssEsc(cls)}`);
9108
+ if (n !== void 0) classCounts[cls] = n;
9109
+ }
9110
+ selectorCounts.classes = classCounts;
8623
9111
  }
8624
- selectorCounts.classes = classCounts;
9112
+ } catch {
8625
9113
  }
8626
- } catch {
8627
9114
  }
8628
9115
  let rolePosition = null;
8629
9116
  const ancestors = [];
@@ -8712,8 +9199,8 @@ function probeElementAttrs(el, arg) {
8712
9199
  }
8713
9200
  return total;
8714
9201
  };
8715
- const nodes = doc.querySelectorAll(roleSources);
8716
- const nodesUsable = nodes.length <= 4e3;
9202
+ const nodes = countMatches ? doc.querySelectorAll(roleSources) : [];
9203
+ const nodesUsable = countMatches && nodes.length <= 4e3;
8717
9204
  const rolesUsable = !!targetRole && nodesUsable;
8718
9205
  if (rolesUsable) {
8719
9206
  let roleCountAll = 0;
@@ -8747,17 +9234,18 @@ function probeElementAttrs(el, arg) {
8747
9234
  };
8748
9235
  }
8749
9236
  }
8750
- if (textNeedle) {
9237
+ if (textNeedle && countMatches) {
8751
9238
  const textCount = countTextOwners(doc.body || doc.documentElement, 4e3);
8752
9239
  if (textCount >= 0) selectorCounts.text = textCount;
8753
9240
  }
8754
- if (rolesUsable || textNeedle) {
9241
+ if (rolesUsable || textNeedle || !countMatches) {
8755
9242
  const CONTAINER_TAGS = ["form", "nav", "main", "article", "section", "dialog", "table", "li", "tr"];
8756
9243
  const NOISY_DATA_ATTR = /^data-(v-[0-9a-f]+|reactid|react-checksum|svelte-\w+|ng-\w+|ember\w*)$/i;
8757
9244
  const POSITIONAL_DATA_ATTR = /^data-(index|idx|i|key|row|rownum|col|column|position|pos|order|sort|offset|page)$/i;
8758
9245
  const TEST_DATA_ATTRS2 = ["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"];
9246
+ const testIdName = testIdAttr.toLowerCase();
8759
9247
  const usableDataAttr = (name, value) => {
8760
- if (!name || name.slice(0, 5) !== "data-" || name === "data-testid") return false;
9248
+ if (!name || name.slice(0, 5) !== "data-" || name.toLowerCase() === testIdName) return false;
8761
9249
  if (NOISY_DATA_ATTR.test(name) || POSITIONAL_DATA_ATTR.test(name)) return false;
8762
9250
  return !!value && value.length <= 120;
8763
9251
  };
@@ -8787,7 +9275,13 @@ function probeElementAttrs(el, arg) {
8787
9275
  return digits <= letters;
8788
9276
  };
8789
9277
  const usableDiscriminator = (t) => !!t && t.length <= 60 && t !== targetText && namesRatherThanReports(t);
9278
+ const hasAtMost = (root, cap) => {
9279
+ const walker = doc.createTreeWalker(root, 1);
9280
+ for (let n = 0; walker.nextNode(); n++) if (n >= cap) return false;
9281
+ return true;
9282
+ };
8790
9283
  const discriminatingText = (anc) => {
9284
+ if (!countMatches && !hasAtMost(anc, 200)) return null;
8791
9285
  const heading = anc.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]');
8792
9286
  if (heading) {
8793
9287
  const t = rawText(heading);
@@ -8819,7 +9313,7 @@ function probeElementAttrs(el, arg) {
8819
9313
  depth++;
8820
9314
  const tag = (node.tagName || "").toLowerCase();
8821
9315
  if (tag === "body" || tag === "html") break;
8822
- const testId = node.getAttribute("data-testid");
9316
+ const testId = node.getAttribute(testIdAttr);
8823
9317
  const id = node.getAttribute("id");
8824
9318
  const explicitRole = node.getAttribute("role");
8825
9319
  const ariaLabel = node.getAttribute("aria-label");
@@ -8839,10 +9333,10 @@ function probeElementAttrs(el, arg) {
8839
9333
  }
8840
9334
  }
8841
9335
  }
8842
- const scopedTextCount = countTextOwners(node, 2e3);
9336
+ const scopedTextCount = countMatches ? countTextOwners(node, 2e3) : -1;
8843
9337
  let filterText = null;
8844
9338
  try {
8845
- if (anchorRole && nodesUsable) filterText = discriminatingText(node);
9339
+ if (anchorRole && (nodesUsable || !countMatches)) filterText = discriminatingText(node);
8846
9340
  } catch {
8847
9341
  filterText = null;
8848
9342
  }
@@ -8855,11 +9349,13 @@ function probeElementAttrs(el, arg) {
8855
9349
  ariaLabel: ariaLabel || null,
8856
9350
  ...scopedRoleCount >= 0 ? { scopedRoleCount } : {},
8857
9351
  ...scopedTextCount >= 0 ? { scopedTextCount } : {},
8858
- ...testId ? { testIdCount: count(`[data-testid=${JSON.stringify(testId)}]`) } : {},
8859
- ...id ? { idCount: count(`#${cssEsc(id)}`) } : {},
9352
+ ...testId && countMatches ? { testIdCount: count(`[${testIdAttr}=${JSON.stringify(testId)}]`) } : {},
9353
+ ...id && countMatches ? { idCount: count(`#${cssEsc(id)}`) } : {},
8860
9354
  ...anchorRole && nodesUsable ? { roleCount: docRoleCount(anchorRole) } : {},
8861
- ...filterText ? { filterText, filterRoleCount: filterMatchCount(anchorRole, filterText) } : {},
8862
- ...dataAttr ? { dataAttr, dataAttrCount: count(`[${dataAttr.name}=${JSON.stringify(dataAttr.value)}]`) } : {}
9355
+ ...filterText ? { filterText } : {},
9356
+ ...filterText && countMatches ? { filterRoleCount: filterMatchCount(anchorRole, filterText) } : {},
9357
+ ...dataAttr ? { dataAttr } : {},
9358
+ ...dataAttr && countMatches ? { dataAttrCount: count(`[${dataAttr.name}=${JSON.stringify(dataAttr.value)}]`) } : {}
8863
9359
  });
8864
9360
  }
8865
9361
  node = node.parentElement;
@@ -8897,6 +9393,17 @@ function installPickerOverlay(arg) {
8897
9393
  return;
8898
9394
  }
8899
9395
  const Z = 2147483600;
9396
+ const testIdAttr = arg.testIdAttribute || "data-testid";
9397
+ const words = {
9398
+ banner: "Piwi inspector \u2014 click any element to generate locators for it",
9399
+ keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
9400
+ keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
9401
+ analyzing: "Analyzing element\u2026"
9402
+ };
9403
+ for (const key of Object.keys(words)) {
9404
+ const given = arg.strings?.[key];
9405
+ if (typeof given === "string" && given) words[key] = given;
9406
+ }
8900
9407
  const highlight = doc.createElement("div");
8901
9408
  highlight.id = "__piwi_picker_highlight";
8902
9409
  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);`;
@@ -8921,27 +9428,53 @@ function installPickerOverlay(arg) {
8921
9428
  const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
8922
9429
  const hlLocator = (expr) => `<code style="font-family:${MONO}">${hlTokens(expr)}</code>`;
8923
9430
  const head = doc.createElement("div");
8924
- head.innerHTML = arg.transport === "postMessage" ? "Click an element to generate locators" : arg.failing ? `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}` : "Piwi inspector \u2014 click any element to generate locators for it";
9431
+ if (arg.transport === "postMessage") head.textContent = "Click an element to generate locators";
9432
+ else if (arg.failing)
9433
+ head.innerHTML = `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}`;
9434
+ else head.textContent = words.banner;
8925
9435
  const locatorLine = doc.createElement("div");
8926
9436
  locatorLine.id = "__piwi_picker_locator";
8927
9437
  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;`;
8928
9438
  const foot = doc.createElement("div");
8929
9439
  foot.id = "__piwi_picker_foot";
8930
9440
  foot.style.cssText = "color:#9ca3af;margin-top:6px;font-size:12px;";
8931
- foot.textContent = "\u2191 parent \xB7 \u2193 child \xB7 Esc skip";
9441
+ foot.textContent = words.keys;
8932
9442
  banner.appendChild(head);
8933
9443
  banner.appendChild(locatorLine);
8934
9444
  banner.appendChild(foot);
8935
9445
  const label = doc.createElement("div");
8936
9446
  label.id = "__piwi_picker_label";
8937
9447
  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);`;
8938
- doc.body.appendChild(highlight);
8939
- doc.body.appendChild(label);
8940
- doc.body.appendChild(banner);
9448
+ const mountParent = () => {
9449
+ let modal = null;
9450
+ try {
9451
+ modal = doc.querySelector("dialog:modal");
9452
+ } catch {
9453
+ modal = null;
9454
+ }
9455
+ if (!modal) return doc.body;
9456
+ const s = g.getComputedStyle(modal);
9457
+ 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);
9458
+ return holdsFixed ? doc.body : modal;
9459
+ };
9460
+ const mount = (...nodes) => {
9461
+ const parent = mountParent();
9462
+ for (const node of nodes) parent.appendChild(node);
9463
+ if (parent !== doc.body) {
9464
+ parent.addEventListener(
9465
+ "close",
9466
+ () => {
9467
+ for (const node of nodes) if (node.parentNode === parent) doc.body.appendChild(node);
9468
+ },
9469
+ { once: true }
9470
+ );
9471
+ }
9472
+ };
9473
+ mount(highlight, label, banner);
8941
9474
  const escJs = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
8942
9475
  const describe = (el) => {
8943
9476
  const tag = (el.tagName || "?").toLowerCase();
8944
- const testId = el.getAttribute && el.getAttribute("data-testid");
9477
+ const testId = el.getAttribute && el.getAttribute(testIdAttr);
8945
9478
  if (testId) return `getByTestId('${escJs(testId)}')`;
8946
9479
  if (el.labels && el.labels.length > 0) {
8947
9480
  const labelText = (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
@@ -8976,7 +9509,7 @@ function installPickerOverlay(arg) {
8976
9509
  const el = chain2[i];
8977
9510
  const tag = (el.tagName || "").toLowerCase();
8978
9511
  if (ACTIONABLE_TAGS.includes(tag)) return i;
8979
- if (el.getAttribute && (el.getAttribute("role") || el.getAttribute("data-testid"))) return i;
9512
+ if (el.getAttribute && (el.getAttribute("role") || el.getAttribute(testIdAttr))) return i;
8980
9513
  }
8981
9514
  return 0;
8982
9515
  };
@@ -9032,7 +9565,7 @@ function installPickerOverlay(arg) {
9032
9565
  locatorLine.style.display = "block";
9033
9566
  }
9034
9567
  placeLabel(r);
9035
- foot.textContent = "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip";
9568
+ foot.textContent = words.keysHovering;
9036
9569
  };
9037
9570
  const redescribe = () => {
9038
9571
  labeled = null;
@@ -9095,6 +9628,13 @@ function installPickerOverlay(arg) {
9095
9628
  banner.remove();
9096
9629
  if (g.__piwiRedescribe === redescribe) delete g.__piwiRedescribe;
9097
9630
  };
9631
+ const teardown = () => {
9632
+ removePickingListeners();
9633
+ removeSuppressed();
9634
+ doc.removeEventListener("click", stop, true);
9635
+ doc.removeEventListener("keydown", stop, true);
9636
+ cleanup();
9637
+ };
9098
9638
  const reportPicked = (el) => {
9099
9639
  removePickingListeners();
9100
9640
  highlight.style.display = "none";
@@ -9105,13 +9645,13 @@ function installPickerOverlay(arg) {
9105
9645
  g.__piwiSnapshotExtras?.onPick?.();
9106
9646
  doc.addEventListener("click", stop, true);
9107
9647
  doc.addEventListener("keydown", stop, true);
9108
- foot.textContent = "Analyzing element\u2026";
9648
+ foot.textContent = words.analyzing;
9109
9649
  g.parent.postMessage({ type: "elementPicked", attrs }, "*");
9110
9650
  } else {
9111
9651
  removeSuppressed();
9112
9652
  g.__piwiPickedElement = el;
9113
9653
  g.__piwiPickState = "picked";
9114
- foot.textContent = "Analyzing element\u2026";
9654
+ foot.textContent = words.analyzing;
9115
9655
  }
9116
9656
  };
9117
9657
  const reportSkipped = () => {
@@ -9159,7 +9699,7 @@ function installPickerOverlay(arg) {
9159
9699
  }
9160
9700
  });
9161
9701
  };
9162
- g.__piwiPickCleanup = cleanup;
9702
+ g.__piwiPickCleanup = teardown;
9163
9703
  doc.addEventListener("mousemove", onMove, true);
9164
9704
  doc.addEventListener("click", onClick, true);
9165
9705
  doc.addEventListener("keydown", onKey, true);
@@ -9181,6 +9721,26 @@ function showAnchorPicker(arg) {
9181
9721
  }
9182
9722
  const Z = 2147483600;
9183
9723
  const { tagRoles, inputRoles, roleSources, leafRole, leafLevel, leafTestId } = arg;
9724
+ const testIdAttr = arg.testIdAttribute || "data-testid";
9725
+ const words = {
9726
+ title: "Scope to stable parents (optional)",
9727
+ hint: "Pick one or more parents to anchor the locator to. Hover a row to see the parent.",
9728
+ noneSelected: "No parents selected \u2014 standard alternatives only.",
9729
+ matchesOne: "\u2713 Selection matches exactly 1 element",
9730
+ matchesMany: "\u2717 Selection matches {count} elements",
9731
+ countUnavailable: "Match count unavailable",
9732
+ containsOne: "contains exactly 1 matching element",
9733
+ containsMany: "contains {count} matching elements",
9734
+ needsTestId: `add a ${testIdAttr} to make this usable`,
9735
+ noHook: "no stable hook",
9736
+ use: "Use selected parents",
9737
+ skip: "Skip (Esc)"
9738
+ };
9739
+ for (const key of Object.keys(words)) {
9740
+ const given = arg.strings?.[key];
9741
+ if (typeof given === "string" && given) words[key] = given;
9742
+ }
9743
+ const counted = (template, n) => template.replace("{count}", String(n));
9184
9744
  const roleOf2 = (n) => {
9185
9745
  const explicit = n.getAttribute && n.getAttribute("role");
9186
9746
  if (explicit) return explicit;
@@ -9196,9 +9756,11 @@ function showAnchorPicker(arg) {
9196
9756
  const al = n.getAttribute && n.getAttribute("aria-level");
9197
9757
  return al && /^\d+$/.test(al) ? Number(al) : null;
9198
9758
  };
9759
+ const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
9199
9760
  const leafMatches = (scope) => {
9200
9761
  try {
9201
- if (leafTestId) return scope.querySelectorAll(`[data-testid=${JSON.stringify(leafTestId)}]`).length;
9762
+ if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
9763
+ if (!leafCountable) return -1;
9202
9764
  const nodes = scope.querySelectorAll(roleSources);
9203
9765
  if (nodes.length > 2e3) return -1;
9204
9766
  let matched = 0;
@@ -9234,12 +9796,12 @@ function showAnchorPicker(arg) {
9234
9796
  depth++;
9235
9797
  const tag = (node.tagName || "").toLowerCase();
9236
9798
  if (tag === "body" || tag === "html") break;
9237
- const testId = node.getAttribute("data-testid");
9799
+ const testId = node.getAttribute(testIdAttr);
9238
9800
  const id = node.getAttribute("id");
9239
9801
  const ariaLabel = node.getAttribute("aria-label");
9240
9802
  const role = roleOf2(node);
9241
9803
  const info = { tag, depth, testId: testId || null, id: id || null, ariaLabel: ariaLabel || null, role };
9242
- if (testId) info.testIdCount = count(`[data-testid=${JSON.stringify(testId)}]`);
9804
+ if (testId) info.testIdCount = count(`[${testIdAttr}=${JSON.stringify(testId)}]`);
9243
9805
  if (id) {
9244
9806
  try {
9245
9807
  info.idCount = count(`#${doc.defaultView.CSS.escape(id)}`);
@@ -9258,7 +9820,7 @@ function showAnchorPicker(arg) {
9258
9820
  if (ariaLabel) info.labeledRoleCount = labeledCount;
9259
9821
  }
9260
9822
  info.scopedLeafCount = leafMatches(node);
9261
- const hookLabel = testId ? `data-testid="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : "no stable hook";
9823
+ const hookLabel = testId ? `${testIdAttr}="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : words.noHook;
9262
9824
  rows.push({
9263
9825
  node,
9264
9826
  info,
@@ -9280,13 +9842,13 @@ function showAnchorPicker(arg) {
9280
9842
  const pr = el.getBoundingClientRect();
9281
9843
  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;`;
9282
9844
  const panel = doc.createElement("div");
9283
- 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);`;
9845
+ 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);`;
9284
9846
  const title = doc.createElement("div");
9285
9847
  title.style.cssText = "font-weight:600;font-size:13px;margin-bottom:2px;";
9286
- title.textContent = "Scope to stable parents (optional)";
9848
+ title.textContent = words.title;
9287
9849
  const sub = doc.createElement("div");
9288
9850
  sub.style.cssText = "color:#9ca3af;margin-bottom:10px;";
9289
- sub.textContent = "Pick one or more parents to anchor the locator to. Hover a row to see the parent.";
9851
+ sub.textContent = words.hint;
9290
9852
  panel.appendChild(title);
9291
9853
  panel.appendChild(sub);
9292
9854
  const selected = /* @__PURE__ */ new Set();
@@ -9294,7 +9856,7 @@ function showAnchorPicker(arg) {
9294
9856
  footer.style.cssText = "margin:10px 0;font-weight:600;";
9295
9857
  const segMatches = (scope, info) => {
9296
9858
  try {
9297
- if (info.testId) return Array.from(scope.querySelectorAll(`[data-testid=${JSON.stringify(info.testId)}]`));
9859
+ if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
9298
9860
  if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
9299
9861
  const nodes = Array.from(scope.querySelectorAll(roleSources));
9300
9862
  if (nodes.length > 2e3) return [];
@@ -9318,14 +9880,15 @@ function showAnchorPicker(arg) {
9318
9880
  let total = 0;
9319
9881
  for (const s of scopes) {
9320
9882
  const c = leafMatches(s);
9321
- if (c > 0) total += c;
9883
+ if (c < 0) return -1;
9884
+ total += c;
9322
9885
  if (total > 50) return total;
9323
9886
  }
9324
9887
  return total;
9325
9888
  };
9326
9889
  const refreshFooter = () => {
9327
9890
  if (selected.size === 0) {
9328
- footer.textContent = "No parents selected \u2014 standard alternatives only.";
9891
+ footer.textContent = words.noneSelected;
9329
9892
  footer.style.color = "#9ca3af";
9330
9893
  g.__piwiPickChainCount = void 0;
9331
9894
  return;
@@ -9333,10 +9896,10 @@ function showAnchorPicker(arg) {
9333
9896
  const c = chainCount();
9334
9897
  g.__piwiPickChainCount = c;
9335
9898
  if (c === 1) {
9336
- footer.textContent = "\u2713 Selection matches exactly 1 element";
9899
+ footer.textContent = words.matchesOne;
9337
9900
  footer.style.color = "#4ade80";
9338
9901
  } else {
9339
- footer.textContent = c < 0 ? "Match count unavailable" : `\u2717 Selection matches ${c} elements`;
9902
+ footer.textContent = c < 0 ? words.countUnavailable : counted(words.matchesMany, c);
9340
9903
  footer.style.color = "#fbbf24";
9341
9904
  }
9342
9905
  };
@@ -9353,7 +9916,8 @@ function showAnchorPicker(arg) {
9353
9916
  code.textContent = `<${row.info.tag}> ${row.hookLabel}`;
9354
9917
  const hint = doc.createElement("span");
9355
9918
  hint.style.cssText = "color:#9ca3af;";
9356
- hint.textContent = row.selectable ? row.info.scopedLeafCount === 1 ? "contains exactly 1 matching element" : `contains ${row.info.scopedLeafCount ?? "?"} matching elements` : "add a data-testid to make this usable";
9919
+ const inside = row.info.scopedLeafCount ?? -1;
9920
+ hint.textContent = row.selectable ? inside === 1 ? words.containsOne : inside < 0 ? words.countUnavailable : counted(words.containsMany, inside) : words.needsTestId;
9357
9921
  text.appendChild(code);
9358
9922
  text.appendChild(hint);
9359
9923
  line.appendChild(box);
@@ -9391,6 +9955,7 @@ function showAnchorPicker(arg) {
9391
9955
  outline.remove();
9392
9956
  outlineLabel.remove();
9393
9957
  pickedOutline.remove();
9958
+ if (g.__piwiAnchorCleanup === cleanup) delete g.__piwiAnchorCleanup;
9394
9959
  };
9395
9960
  const done = (state) => {
9396
9961
  g.__piwiPickAnchors = state === "done" ? rows.filter((_, i) => selected.has(i)).map((r) => r.info) : [];
@@ -9407,7 +9972,7 @@ function showAnchorPicker(arg) {
9407
9972
  buttonRow.style.cssText = "display:flex;gap:8px;margin-top:4px;";
9408
9973
  const useBtn = doc.createElement("button");
9409
9974
  useBtn.style.cssText = "flex:1;background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px;cursor:pointer;font:600 12px system-ui;";
9410
- useBtn.textContent = "Use selected parents";
9975
+ useBtn.textContent = words.use;
9411
9976
  useBtn.addEventListener("click", (e) => {
9412
9977
  e.preventDefault();
9413
9978
  e.stopImmediatePropagation();
@@ -9415,7 +9980,7 @@ function showAnchorPicker(arg) {
9415
9980
  });
9416
9981
  const skipBtn = doc.createElement("button");
9417
9982
  skipBtn.style.cssText = "background:none;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:8px 10px;cursor:pointer;font:12px system-ui;";
9418
- skipBtn.textContent = "Skip (Esc)";
9983
+ skipBtn.textContent = words.skip;
9419
9984
  skipBtn.addEventListener("click", (e) => {
9420
9985
  e.preventDefault();
9421
9986
  e.stopImmediatePropagation();
@@ -9427,10 +9992,32 @@ function showAnchorPicker(arg) {
9427
9992
  refreshFooter();
9428
9993
  g.__piwiAnchorCleanup = cleanup;
9429
9994
  doc.addEventListener("keydown", onKey, true);
9430
- doc.body.appendChild(pickedOutline);
9431
- doc.body.appendChild(outline);
9432
- doc.body.appendChild(outlineLabel);
9433
- doc.body.appendChild(panel);
9995
+ const mountParent = () => {
9996
+ let modal = null;
9997
+ try {
9998
+ modal = doc.querySelector("dialog:modal");
9999
+ } catch {
10000
+ modal = null;
10001
+ }
10002
+ if (!modal) return doc.body;
10003
+ const s = g.getComputedStyle(modal);
10004
+ 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);
10005
+ return holdsFixed ? doc.body : modal;
10006
+ };
10007
+ const mount = (...nodes) => {
10008
+ const parent = mountParent();
10009
+ for (const node2 of nodes) parent.appendChild(node2);
10010
+ if (parent !== doc.body) {
10011
+ parent.addEventListener(
10012
+ "close",
10013
+ () => {
10014
+ for (const node2 of nodes) if (node2.parentNode === parent) doc.body.appendChild(node2);
10015
+ },
10016
+ { once: true }
10017
+ );
10018
+ }
10019
+ };
10020
+ mount(pickedOutline, outline, outlineLabel, panel);
9434
10021
  }
9435
10022
 
9436
10023
  // ../picker-dom/src/overlay-confirm.ts
@@ -9646,7 +10233,7 @@ function isAutoGenerated(value) {
9646
10233
  return false;
9647
10234
  }
9648
10235
  var TEST_DATA_ATTRS = /* @__PURE__ */ new Set(["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"]);
9649
- function generateAlternatives(attrs) {
10236
+ function generateAlternatives(attrs, options = {}) {
9650
10237
  const alts = [];
9651
10238
  const seen = /* @__PURE__ */ new Set();
9652
10239
  const add = (loc) => {
@@ -9657,12 +10244,14 @@ function generateAlternatives(attrs) {
9657
10244
  };
9658
10245
  const { accessibleName } = attrs;
9659
10246
  const tag = attrs.tagName;
9660
- const role = resolveAriaRole(attrs);
10247
+ const role = options.role !== void 0 ? options.role : resolveAriaRole(attrs);
9661
10248
  const counts = attrs.selectorCounts;
9662
10249
  const isUnique = (n) => n == null || n <= 1;
9663
10250
  const ambiguityPenalty = (n) => n != null && n > 1 ? 45 : 0;
9664
10251
  const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
9665
- const testId = attr(attrs, "data-testid");
10252
+ const testIdAttribute = options.testIdAttribute || "data-testid";
10253
+ const selectable = (name2) => name2.toLowerCase() !== testIdAttribute.toLowerCase();
10254
+ const testId = attr(attrs, testIdAttribute);
9666
10255
  if (testId && isUnique(counts?.testId)) {
9667
10256
  add({
9668
10257
  locator: `getByTestId('${esc(testId)}')`,
@@ -9721,7 +10310,7 @@ function generateAlternatives(attrs) {
9721
10310
  });
9722
10311
  }
9723
10312
  const id = attr(attrs, "id");
9724
- if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
10313
+ if (id && selectable("id") && !isAutoGenerated(id) && isUnique(counts?.id)) {
9725
10314
  const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
9726
10315
  add({
9727
10316
  locator: `locator('${esc(selector)}')`,
@@ -9731,7 +10320,7 @@ function generateAlternatives(attrs) {
9731
10320
  });
9732
10321
  }
9733
10322
  const name = attr(attrs, "name");
9734
- if (name && isUnique(counts?.name)) {
10323
+ if (name && selectable("name") && isUnique(counts?.name)) {
9735
10324
  const selector = `[name="${escCssAttrValue(name)}"]`;
9736
10325
  add({
9737
10326
  locator: `locator('${esc(selector)}')`,
@@ -9776,7 +10365,7 @@ function generateAlternatives(attrs) {
9776
10365
  score: scores.testId
9777
10366
  });
9778
10367
  }
9779
- if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
10368
+ if (!idAnchorDone && anc.id && selectable("id") && !isAutoGenerated(anc.id) && anc.idCount === 1) {
9780
10369
  idAnchorDone = true;
9781
10370
  const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
9782
10371
  add({
@@ -9786,7 +10375,7 @@ function generateAlternatives(attrs) {
9786
10375
  score: scores.id
9787
10376
  });
9788
10377
  }
9789
- if (!dataAnchorDone && anc.dataAttr && anc.dataAttrCount === 1) {
10378
+ if (!dataAnchorDone && anc.dataAttr && selectable(anc.dataAttr.name) && anc.dataAttrCount === 1) {
9790
10379
  dataAnchorDone = true;
9791
10380
  const { name: name2, value } = anc.dataAttr;
9792
10381
  const anchorSelector = `[${name2}="${escCssAttrValue(value)}"]`;