@piwitests/reporter 0.45.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.
@@ -335,6 +335,17 @@ function installPickerOverlay(arg) {
335
335
  return;
336
336
  }
337
337
  const Z = 2147483600;
338
+ const testIdAttr = arg.testIdAttribute || "data-testid";
339
+ const words = {
340
+ banner: "Piwi inspector \u2014 click any element to generate locators for it",
341
+ keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
342
+ keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
343
+ analyzing: "Analyzing element\u2026"
344
+ };
345
+ for (const key of Object.keys(words)) {
346
+ const given = arg.strings?.[key];
347
+ if (typeof given === "string" && given) words[key] = given;
348
+ }
338
349
  const highlight = doc.createElement("div");
339
350
  highlight.id = "__piwi_picker_highlight";
340
351
  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);`;
@@ -359,27 +370,53 @@ function installPickerOverlay(arg) {
359
370
  const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
360
371
  const hlLocator = (expr) => `<code style="font-family:${MONO}">${hlTokens(expr)}</code>`;
361
372
  const head = doc.createElement("div");
362
- 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";
373
+ if (arg.transport === "postMessage") head.textContent = "Click an element to generate locators";
374
+ else if (arg.failing)
375
+ head.innerHTML = `Piwi locator picker \u2014 click the element that should replace ${hlLocator(arg.failing)}`;
376
+ else head.textContent = words.banner;
363
377
  const locatorLine = doc.createElement("div");
364
378
  locatorLine.id = "__piwi_picker_locator";
365
379
  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;`;
366
380
  const foot = doc.createElement("div");
367
381
  foot.id = "__piwi_picker_foot";
368
382
  foot.style.cssText = "color:#9ca3af;margin-top:6px;font-size:12px;";
369
- foot.textContent = "\u2191 parent \xB7 \u2193 child \xB7 Esc skip";
383
+ foot.textContent = words.keys;
370
384
  banner.appendChild(head);
371
385
  banner.appendChild(locatorLine);
372
386
  banner.appendChild(foot);
373
387
  const label = doc.createElement("div");
374
388
  label.id = "__piwi_picker_label";
375
389
  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);`;
376
- doc.body.appendChild(highlight);
377
- doc.body.appendChild(label);
378
- doc.body.appendChild(banner);
390
+ const mountParent = () => {
391
+ let modal = null;
392
+ try {
393
+ modal = doc.querySelector("dialog:modal");
394
+ } catch {
395
+ modal = null;
396
+ }
397
+ if (!modal) return doc.body;
398
+ const s = g.getComputedStyle(modal);
399
+ 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);
400
+ return holdsFixed ? doc.body : modal;
401
+ };
402
+ const mount = (...nodes) => {
403
+ const parent = mountParent();
404
+ for (const node of nodes) parent.appendChild(node);
405
+ if (parent !== doc.body) {
406
+ parent.addEventListener(
407
+ "close",
408
+ () => {
409
+ for (const node of nodes) if (node.parentNode === parent) doc.body.appendChild(node);
410
+ },
411
+ { once: true }
412
+ );
413
+ }
414
+ };
415
+ mount(highlight, label, banner);
379
416
  const escJs = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
380
417
  const describe = (el) => {
381
418
  const tag = (el.tagName || "?").toLowerCase();
382
- const testId = el.getAttribute && el.getAttribute("data-testid");
419
+ const testId = el.getAttribute && el.getAttribute(testIdAttr);
383
420
  if (testId) return `getByTestId('${escJs(testId)}')`;
384
421
  if (el.labels && el.labels.length > 0) {
385
422
  const labelText = (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
@@ -414,7 +451,7 @@ function installPickerOverlay(arg) {
414
451
  const el = chain2[i];
415
452
  const tag = (el.tagName || "").toLowerCase();
416
453
  if (ACTIONABLE_TAGS.includes(tag)) return i;
417
- if (el.getAttribute && (el.getAttribute("role") || el.getAttribute("data-testid"))) return i;
454
+ if (el.getAttribute && (el.getAttribute("role") || el.getAttribute(testIdAttr))) return i;
418
455
  }
419
456
  return 0;
420
457
  };
@@ -470,7 +507,7 @@ function installPickerOverlay(arg) {
470
507
  locatorLine.style.display = "block";
471
508
  }
472
509
  placeLabel(r);
473
- foot.textContent = "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip";
510
+ foot.textContent = words.keysHovering;
474
511
  };
475
512
  const redescribe = () => {
476
513
  labeled = null;
@@ -533,6 +570,13 @@ function installPickerOverlay(arg) {
533
570
  banner.remove();
534
571
  if (g.__piwiRedescribe === redescribe) delete g.__piwiRedescribe;
535
572
  };
573
+ const teardown = () => {
574
+ removePickingListeners();
575
+ removeSuppressed();
576
+ doc.removeEventListener("click", stop, true);
577
+ doc.removeEventListener("keydown", stop, true);
578
+ cleanup();
579
+ };
536
580
  const reportPicked = (el) => {
537
581
  removePickingListeners();
538
582
  highlight.style.display = "none";
@@ -543,13 +587,13 @@ function installPickerOverlay(arg) {
543
587
  g.__piwiSnapshotExtras?.onPick?.();
544
588
  doc.addEventListener("click", stop, true);
545
589
  doc.addEventListener("keydown", stop, true);
546
- foot.textContent = "Analyzing element\u2026";
590
+ foot.textContent = words.analyzing;
547
591
  g.parent.postMessage({ type: "elementPicked", attrs }, "*");
548
592
  } else {
549
593
  removeSuppressed();
550
594
  g.__piwiPickedElement = el;
551
595
  g.__piwiPickState = "picked";
552
- foot.textContent = "Analyzing element\u2026";
596
+ foot.textContent = words.analyzing;
553
597
  }
554
598
  };
555
599
  const reportSkipped = () => {
@@ -597,7 +641,7 @@ function installPickerOverlay(arg) {
597
641
  }
598
642
  });
599
643
  };
600
- g.__piwiPickCleanup = cleanup;
644
+ g.__piwiPickCleanup = teardown;
601
645
  doc.addEventListener("mousemove", onMove, true);
602
646
  doc.addEventListener("click", onClick, true);
603
647
  doc.addEventListener("keydown", onKey, true);
@@ -619,6 +663,26 @@ function showAnchorPicker(arg) {
619
663
  }
620
664
  const Z = 2147483600;
621
665
  const { tagRoles, inputRoles, roleSources, leafRole, leafLevel, leafTestId } = arg;
666
+ const testIdAttr = arg.testIdAttribute || "data-testid";
667
+ const words = {
668
+ title: "Scope to stable parents (optional)",
669
+ hint: "Pick one or more parents to anchor the locator to. Hover a row to see the parent.",
670
+ noneSelected: "No parents selected \u2014 standard alternatives only.",
671
+ matchesOne: "\u2713 Selection matches exactly 1 element",
672
+ matchesMany: "\u2717 Selection matches {count} elements",
673
+ countUnavailable: "Match count unavailable",
674
+ containsOne: "contains exactly 1 matching element",
675
+ containsMany: "contains {count} matching elements",
676
+ needsTestId: `add a ${testIdAttr} to make this usable`,
677
+ noHook: "no stable hook",
678
+ use: "Use selected parents",
679
+ skip: "Skip (Esc)"
680
+ };
681
+ for (const key of Object.keys(words)) {
682
+ const given = arg.strings?.[key];
683
+ if (typeof given === "string" && given) words[key] = given;
684
+ }
685
+ const counted = (template, n) => template.replace("{count}", String(n));
622
686
  const roleOf = (n) => {
623
687
  const explicit = n.getAttribute && n.getAttribute("role");
624
688
  if (explicit) return explicit;
@@ -634,9 +698,11 @@ function showAnchorPicker(arg) {
634
698
  const al = n.getAttribute && n.getAttribute("aria-level");
635
699
  return al && /^\d+$/.test(al) ? Number(al) : null;
636
700
  };
701
+ const leafCountable = !!leafTestId || roleOf(el) === leafRole;
637
702
  const leafMatches = (scope) => {
638
703
  try {
639
- if (leafTestId) return scope.querySelectorAll(`[data-testid=${JSON.stringify(leafTestId)}]`).length;
704
+ if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
705
+ if (!leafCountable) return -1;
640
706
  const nodes = scope.querySelectorAll(roleSources);
641
707
  if (nodes.length > 2e3) return -1;
642
708
  let matched = 0;
@@ -672,12 +738,12 @@ function showAnchorPicker(arg) {
672
738
  depth++;
673
739
  const tag = (node.tagName || "").toLowerCase();
674
740
  if (tag === "body" || tag === "html") break;
675
- const testId = node.getAttribute("data-testid");
741
+ const testId = node.getAttribute(testIdAttr);
676
742
  const id = node.getAttribute("id");
677
743
  const ariaLabel = node.getAttribute("aria-label");
678
744
  const role = roleOf(node);
679
745
  const info = { tag, depth, testId: testId || null, id: id || null, ariaLabel: ariaLabel || null, role };
680
- if (testId) info.testIdCount = count(`[data-testid=${JSON.stringify(testId)}]`);
746
+ if (testId) info.testIdCount = count(`[${testIdAttr}=${JSON.stringify(testId)}]`);
681
747
  if (id) {
682
748
  try {
683
749
  info.idCount = count(`#${doc.defaultView.CSS.escape(id)}`);
@@ -696,7 +762,7 @@ function showAnchorPicker(arg) {
696
762
  if (ariaLabel) info.labeledRoleCount = labeledCount;
697
763
  }
698
764
  info.scopedLeafCount = leafMatches(node);
699
- const hookLabel = testId ? `data-testid="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : "no stable hook";
765
+ const hookLabel = testId ? `${testIdAttr}="${testId}"` : id ? `#${id}` : ariaLabel && role ? `${role} "${ariaLabel}"` : role ? `role ${role}` : words.noHook;
700
766
  rows.push({
701
767
  node,
702
768
  info,
@@ -718,13 +784,13 @@ function showAnchorPicker(arg) {
718
784
  const pr = el.getBoundingClientRect();
719
785
  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;`;
720
786
  const panel = doc.createElement("div");
721
- 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);`;
787
+ 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);`;
722
788
  const title = doc.createElement("div");
723
789
  title.style.cssText = "font-weight:600;font-size:13px;margin-bottom:2px;";
724
- title.textContent = "Scope to stable parents (optional)";
790
+ title.textContent = words.title;
725
791
  const sub = doc.createElement("div");
726
792
  sub.style.cssText = "color:#9ca3af;margin-bottom:10px;";
727
- sub.textContent = "Pick one or more parents to anchor the locator to. Hover a row to see the parent.";
793
+ sub.textContent = words.hint;
728
794
  panel.appendChild(title);
729
795
  panel.appendChild(sub);
730
796
  const selected = /* @__PURE__ */ new Set();
@@ -732,7 +798,7 @@ function showAnchorPicker(arg) {
732
798
  footer.style.cssText = "margin:10px 0;font-weight:600;";
733
799
  const segMatches = (scope, info) => {
734
800
  try {
735
- if (info.testId) return Array.from(scope.querySelectorAll(`[data-testid=${JSON.stringify(info.testId)}]`));
801
+ if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
736
802
  if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
737
803
  const nodes = Array.from(scope.querySelectorAll(roleSources));
738
804
  if (nodes.length > 2e3) return [];
@@ -756,14 +822,15 @@ function showAnchorPicker(arg) {
756
822
  let total = 0;
757
823
  for (const s of scopes) {
758
824
  const c = leafMatches(s);
759
- if (c > 0) total += c;
825
+ if (c < 0) return -1;
826
+ total += c;
760
827
  if (total > 50) return total;
761
828
  }
762
829
  return total;
763
830
  };
764
831
  const refreshFooter = () => {
765
832
  if (selected.size === 0) {
766
- footer.textContent = "No parents selected \u2014 standard alternatives only.";
833
+ footer.textContent = words.noneSelected;
767
834
  footer.style.color = "#9ca3af";
768
835
  g.__piwiPickChainCount = void 0;
769
836
  return;
@@ -771,10 +838,10 @@ function showAnchorPicker(arg) {
771
838
  const c = chainCount();
772
839
  g.__piwiPickChainCount = c;
773
840
  if (c === 1) {
774
- footer.textContent = "\u2713 Selection matches exactly 1 element";
841
+ footer.textContent = words.matchesOne;
775
842
  footer.style.color = "#4ade80";
776
843
  } else {
777
- footer.textContent = c < 0 ? "Match count unavailable" : `\u2717 Selection matches ${c} elements`;
844
+ footer.textContent = c < 0 ? words.countUnavailable : counted(words.matchesMany, c);
778
845
  footer.style.color = "#fbbf24";
779
846
  }
780
847
  };
@@ -791,7 +858,8 @@ function showAnchorPicker(arg) {
791
858
  code.textContent = `<${row.info.tag}> ${row.hookLabel}`;
792
859
  const hint = doc.createElement("span");
793
860
  hint.style.cssText = "color:#9ca3af;";
794
- 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";
861
+ const inside = row.info.scopedLeafCount ?? -1;
862
+ hint.textContent = row.selectable ? inside === 1 ? words.containsOne : inside < 0 ? words.countUnavailable : counted(words.containsMany, inside) : words.needsTestId;
795
863
  text.appendChild(code);
796
864
  text.appendChild(hint);
797
865
  line.appendChild(box);
@@ -829,6 +897,7 @@ function showAnchorPicker(arg) {
829
897
  outline.remove();
830
898
  outlineLabel.remove();
831
899
  pickedOutline.remove();
900
+ if (g.__piwiAnchorCleanup === cleanup) delete g.__piwiAnchorCleanup;
832
901
  };
833
902
  const done = (state) => {
834
903
  g.__piwiPickAnchors = state === "done" ? rows.filter((_, i) => selected.has(i)).map((r) => r.info) : [];
@@ -845,7 +914,7 @@ function showAnchorPicker(arg) {
845
914
  buttonRow.style.cssText = "display:flex;gap:8px;margin-top:4px;";
846
915
  const useBtn = doc.createElement("button");
847
916
  useBtn.style.cssText = "flex:1;background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px;cursor:pointer;font:600 12px system-ui;";
848
- useBtn.textContent = "Use selected parents";
917
+ useBtn.textContent = words.use;
849
918
  useBtn.addEventListener("click", (e) => {
850
919
  e.preventDefault();
851
920
  e.stopImmediatePropagation();
@@ -853,7 +922,7 @@ function showAnchorPicker(arg) {
853
922
  });
854
923
  const skipBtn = doc.createElement("button");
855
924
  skipBtn.style.cssText = "background:none;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:8px 10px;cursor:pointer;font:12px system-ui;";
856
- skipBtn.textContent = "Skip (Esc)";
925
+ skipBtn.textContent = words.skip;
857
926
  skipBtn.addEventListener("click", (e) => {
858
927
  e.preventDefault();
859
928
  e.stopImmediatePropagation();
@@ -865,10 +934,32 @@ function showAnchorPicker(arg) {
865
934
  refreshFooter();
866
935
  g.__piwiAnchorCleanup = cleanup;
867
936
  doc.addEventListener("keydown", onKey, true);
868
- doc.body.appendChild(pickedOutline);
869
- doc.body.appendChild(outline);
870
- doc.body.appendChild(outlineLabel);
871
- doc.body.appendChild(panel);
937
+ const mountParent = () => {
938
+ let modal = null;
939
+ try {
940
+ modal = doc.querySelector("dialog:modal");
941
+ } catch {
942
+ modal = null;
943
+ }
944
+ if (!modal) return doc.body;
945
+ const s = g.getComputedStyle(modal);
946
+ 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);
947
+ return holdsFixed ? doc.body : modal;
948
+ };
949
+ const mount = (...nodes) => {
950
+ const parent = mountParent();
951
+ for (const node2 of nodes) parent.appendChild(node2);
952
+ if (parent !== doc.body) {
953
+ parent.addEventListener(
954
+ "close",
955
+ () => {
956
+ for (const node2 of nodes) if (node2.parentNode === parent) doc.body.appendChild(node2);
957
+ },
958
+ { once: true }
959
+ );
960
+ }
961
+ };
962
+ mount(pickedOutline, outline, outlineLabel, panel);
872
963
  }
873
964
 
874
965
  // ../picker-dom/src/overlay-confirm.ts
@@ -1068,7 +1159,7 @@ function isAutoGenerated(value) {
1068
1159
  return false;
1069
1160
  }
1070
1161
  var TEST_DATA_ATTRS = /* @__PURE__ */ new Set(["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"]);
1071
- function generateAlternatives(attrs) {
1162
+ function generateAlternatives(attrs, options = {}) {
1072
1163
  const alts = [];
1073
1164
  const seen = /* @__PURE__ */ new Set();
1074
1165
  const add = (loc) => {
@@ -1079,12 +1170,14 @@ function generateAlternatives(attrs) {
1079
1170
  };
1080
1171
  const { accessibleName } = attrs;
1081
1172
  const tag = attrs.tagName;
1082
- const role = resolveAriaRole(attrs);
1173
+ const role = options.role !== void 0 ? options.role : resolveAriaRole(attrs);
1083
1174
  const counts = attrs.selectorCounts;
1084
1175
  const isUnique = (n) => n == null || n <= 1;
1085
1176
  const ambiguityPenalty = (n) => n != null && n > 1 ? 45 : 0;
1086
1177
  const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
1087
- const testId = attr(attrs, "data-testid");
1178
+ const testIdAttribute = options.testIdAttribute || "data-testid";
1179
+ const selectable = (name2) => name2.toLowerCase() !== testIdAttribute.toLowerCase();
1180
+ const testId = attr(attrs, testIdAttribute);
1088
1181
  if (testId && isUnique(counts?.testId)) {
1089
1182
  add({
1090
1183
  locator: `getByTestId('${esc(testId)}')`,
@@ -1143,7 +1236,7 @@ function generateAlternatives(attrs) {
1143
1236
  });
1144
1237
  }
1145
1238
  const id = attr(attrs, "id");
1146
- if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
1239
+ if (id && selectable("id") && !isAutoGenerated(id) && isUnique(counts?.id)) {
1147
1240
  const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
1148
1241
  add({
1149
1242
  locator: `locator('${esc(selector)}')`,
@@ -1153,7 +1246,7 @@ function generateAlternatives(attrs) {
1153
1246
  });
1154
1247
  }
1155
1248
  const name = attr(attrs, "name");
1156
- if (name && isUnique(counts?.name)) {
1249
+ if (name && selectable("name") && isUnique(counts?.name)) {
1157
1250
  const selector = `[name="${escCssAttrValue(name)}"]`;
1158
1251
  add({
1159
1252
  locator: `locator('${esc(selector)}')`,
@@ -1198,7 +1291,7 @@ function generateAlternatives(attrs) {
1198
1291
  score: scores.testId
1199
1292
  });
1200
1293
  }
1201
- if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
1294
+ if (!idAnchorDone && anc.id && selectable("id") && !isAutoGenerated(anc.id) && anc.idCount === 1) {
1202
1295
  idAnchorDone = true;
1203
1296
  const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
1204
1297
  add({
@@ -1208,7 +1301,7 @@ function generateAlternatives(attrs) {
1208
1301
  score: scores.id
1209
1302
  });
1210
1303
  }
1211
- if (!dataAnchorDone && anc.dataAttr && anc.dataAttrCount === 1) {
1304
+ if (!dataAnchorDone && anc.dataAttr && selectable(anc.dataAttr.name) && anc.dataAttrCount === 1) {
1212
1305
  dataAnchorDone = true;
1213
1306
  const { name: name2, value } = anc.dataAttr;
1214
1307
  const anchorSelector = `[${name2}="${escCssAttrValue(value)}"]`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.dev",
@@ -25,6 +25,8 @@ Prefer the **Piwi MCP server** if it is connected (`get_locator_healing`, `expla
25
25
 
26
26
  6. **Report.** Show the before/after locator, the file and line, and the verifying result. If several tests shared the selector, list every place you changed.
27
27
 
28
+ 7. **Report back to Piwi.** Commit with the trailer `Piwi-Cluster: <clusterId>` as the last line of the message (the cluster of the failing case; `get_fix_plan` returns the line in `verify.commitTrailer`), then call `report_fix_attempt` with that cluster, `kind: "locator-edit"`, the commit or the branch, and the `edit` (`filePath`, `line`, `from`: the old locator, `to`: the one you applied). Do it once per call site you changed. Piwi records the attempt verified when the test passes on that commit, and counts the healing suggestion as adopted.
29
+
28
30
  ## Guardrails
29
31
 
30
32
  - Change the **locator**, not the assertion's intent — if a test checked for "Sign in" and the button is now "Log in", that is a copy change to confirm with the user, not a locator to heal.
@@ -27,7 +27,11 @@ Prefer the **Piwi MCP server** if it is connected to this agent (tools named `li
27
27
 
28
28
  7. **Verify.** Re-run the affected spec(s): `npx playwright test <file>`. Confirm they pass and the new run is green in the dashboard (`get_run_insights` compares against the last green run: regressions cleared, nothing new broken).
29
29
 
30
- 8. **Close the loop (optional).** With reporter or admin access, `set_cluster_status` marks the cluster resolved with a note so it drops off the triage queue.
30
+ 8. **Report back.** Piwi learns from what you tell it, so end every investigation with these calls (they need reporter or admin access; each is logged with your API key):
31
+ - **Your diagnosis.** If the cluster had no diagnosis, or yours differs, record it with `record_diagnosis`: the model you run on and the diagnosis in the schema the tool describes (summary, confidenceScore, severity, ranked hypotheses with their evidence, suggestedFix with the unified-diff `patch`). Piwi validates the patch against the source that failed and shows the diagnosis as written by an agent.
32
+ - **Your rating of Piwi's diagnosis.** If you read one with `get_cluster_diagnosis`, rate it with `submit_diagnosis_feedback`: `up` when it pointed at the real cause, `down` with a one-line note on what it got wrong when it did not. A `down` keeps the next diagnosis from repeating it.
33
+ - **Your fix.** Commit the change with the trailer `get_fix_plan` returns in `verify.commitTrailer` (`Piwi-Cluster: <clusterId>`) as the last line of the commit message, then call `report_fix_attempt` with the cluster, the kind (`patch`, `locator-edit` or `fix-plan`), the commit or the branch, the patch you applied and the `diagnosisId` you followed. When the tests pass on that commit, Piwi records the attempt verified and shows it on the cluster's activity.
34
+ - Leave the cluster's status alone: the run that passes on your commit records the fix.
31
35
 
32
36
  ## Guardrails
33
37
 
@@ -14,7 +14,7 @@ drafts it from the graph, and opens it in the same change.
14
14
  ## How you reach Piwi
15
15
 
16
16
  Prefer the **Piwi MCP server** if it is connected (`list_scenario_gaps`,
17
- `draft_scenario`, `get_change_coverage`). Otherwise use the dashboard's **Gaps**
17
+ `draft_scenario`, `get_change_coverage`, `triage_gap`). Otherwise use the dashboard's **Gaps**
18
18
  view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
19
19
  `PIWI_PROJECT_NAME` set.
20
20
 
@@ -55,11 +55,18 @@ view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
55
55
  test asserts the missing behavior. If the gap had no reachable path, say so —
56
56
  the draft cannot invent an entry point, and the gap may need a fixture.
57
57
 
58
+ 7. **Report back to Piwi.** Tell Piwi what you decided about the gap with
59
+ `triage_gap` (reporter or admin access): `verb: "accept"` for the gap you
60
+ wrote the test for, `covered-by` with the covering test's id when a test you
61
+ found already covers it, or `dismiss` with the reason (`not-worth-testing`,
62
+ `covered-elsewhere`, `wrong`) when the evidence did not hold. Each verdict counts toward the precision of the
63
+ detector that raised the gap, so a detector that keeps being wrong is muted.
64
+
58
65
  ## Guardrails
59
66
 
60
67
  - A gap is a suggestion with evidence, never a verdict. If the evidence does not
61
68
  hold — the route is headless by design, the control is decorative — dismiss it
62
- in the dashboard with a reason rather than writing a test that asserts nothing.
69
+ with `triage_gap` and a reason rather than writing a test that asserts nothing.
63
70
  - Do not weaken an assertion to make the draft pass. A test that passes without
64
71
  asserting the behavior is the false-comfort gap you were sent to close.
65
72
  - The draft's assertion is a `TODO` on purpose: fill it from the source and the