@piwitests/reporter 0.41.0 → 0.42.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.
@@ -137,6 +137,7 @@ var PIWI_ENV_KEYS = {
137
137
  aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
138
138
  };
139
139
  var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
140
+ var PIWI_SHARD_ENV = "PIWI_SHARD";
140
141
  function readBool(val) {
141
142
  if (val === void 0) return void 0;
142
143
  return val === "true";
@@ -489,6 +490,20 @@ function detectCiRunLabel() {
489
490
  return null;
490
491
  }
491
492
 
493
+ // src/internal/support/shard-info.ts
494
+ function parseShardSpec(raw) {
495
+ const match = raw?.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
496
+ if (!match) return null;
497
+ const current = Number(match[1]);
498
+ const total = Number(match[2]);
499
+ return current >= 1 && current <= total && total > 1 ? { current, total } : null;
500
+ }
501
+ function resolveShardInfo(config, env = process.env) {
502
+ const pwShard = config.shard;
503
+ if (pwShard && pwShard.total > 1) return { current: pwShard.current, total: pwShard.total };
504
+ return parseShardSpec(env[PIWI_SHARD_ENV]);
505
+ }
506
+
492
507
  // src/internal/collect/metadata-collector.ts
493
508
  var import_node_child_process = require("child_process");
494
509
  var DETACHED_HEAD = "HEAD";
@@ -688,9 +703,7 @@ function createGlobalSetup(options, userSetup) {
688
703
  try {
689
704
  const auth = await httpClient.resolveAuth(opts);
690
705
  const runLabel = opts.runLabel || detectCiRunLabel();
691
- const pwShard = config.shard;
692
- const shardIndex = pwShard?.current;
693
- const shardTotal = pwShard?.total;
706
+ const shard = resolveShardInfo(config);
694
707
  const response = await httpClient.postJSON(
695
708
  "/api/test-runs/setup",
696
709
  {
@@ -701,8 +714,8 @@ function createGlobalSetup(options, userSetup) {
701
714
  keep: opts.keep === true,
702
715
  startTime: (/* @__PURE__ */ new Date()).toISOString(),
703
716
  instanceId: computeInstanceId(opts.projectName, runLabel),
704
- shardIndex,
705
- shardTotal
717
+ shardIndex: shard?.current,
718
+ shardTotal: shard?.total
706
719
  },
707
720
  auth
708
721
  );
package/dist/index.js CHANGED
@@ -143,6 +143,7 @@ var PIWI_SELECTION_ENV = {
143
143
  hash: "PIWI_SELECTION_HASH",
144
144
  count: "PIWI_SELECTION_COUNT"
145
145
  };
146
+ var PIWI_SHARD_ENV = "PIWI_SHARD";
146
147
  var PIWI_PROBE_ENV = {
147
148
  flag: "PIWI_PROBE",
148
149
  plan: "PIWI_PROBE_PLAN",
@@ -2857,6 +2858,13 @@ function maskTokenLike(text) {
2857
2858
  return text.replace(DATA_URI_RE, "data:[masked]").replace(JWT_RE, "[masked-token]").replace(LONG_HEX_RE, "[masked-hex]");
2858
2859
  }
2859
2860
 
2861
+ // ../core/src/locator-break.ts
2862
+ function sameFilePath(a, b) {
2863
+ const x = a.replace(/\\/g, "/").replace(/^\.\//, "");
2864
+ const y = b.replace(/\\/g, "/").replace(/^\.\//, "");
2865
+ return x === y || x.endsWith("/" + y) || y.endsWith("/" + x);
2866
+ }
2867
+
2860
2868
  // ../core/src/step-tree.ts
2861
2869
  var SPAN_TOLERANCE_MS = 1;
2862
2870
  var SETUP_CONTAINER_RE = /^before hooks$/i;
@@ -2878,9 +2886,26 @@ function errorMessage2(step) {
2878
2886
  if (typeof error === "string") return error;
2879
2887
  return str(error?.message);
2880
2888
  }
2889
+ function errorLocation(step) {
2890
+ const error = step.error;
2891
+ return typeof error === "string" ? "" : str(error?.location);
2892
+ }
2893
+ function parseLocation(location) {
2894
+ const m = /^(.+):(\d+):(\d+)$/.exec(location.trim());
2895
+ return m ? { file: m[1], line: Number(m[2]), column: Number(m[3]) } : null;
2896
+ }
2897
+ function sameCodeLocation(a, b) {
2898
+ const x = typeof a === "string" ? parseLocation(a) : a;
2899
+ const y = typeof b === "string" ? parseLocation(b) : b;
2900
+ if (!x || !y) return false;
2901
+ return x.line === y.line && x.column === y.column && sameFilePath(x.file, y.file);
2902
+ }
2881
2903
  function isFailedStep(step) {
2882
2904
  return step.failed === true || errorMessage2(step).trim().length > 0;
2883
2905
  }
2906
+ function isRecoveredStep(step) {
2907
+ return step.recovered === true;
2908
+ }
2884
2909
  function isCaptureStep(step) {
2885
2910
  const title = str(step.title).trim();
2886
2911
  if (CAPTURE_ATTACH_RE.test(title) || CAPTURE_FIXTURE_RE.test(title)) return true;
@@ -2935,18 +2960,72 @@ function stepPhases(steps, parents = stepParents(steps)) {
2935
2960
  return "body";
2936
2961
  });
2937
2962
  }
2938
- function failingStepIndex(steps, parents = stepParents(steps)) {
2963
+ function firstLine(message) {
2964
+ return stripAnsi(str(message)).split("\n")[0].trim();
2965
+ }
2966
+ function errorHeads(error) {
2967
+ return error.split(/\n---\n/).map(firstLine).filter((head) => head.length > 0);
2968
+ }
2969
+ function sameError(inner, outer) {
2970
+ const a = firstLine(errorMessage2(inner));
2971
+ const b = firstLine(errorMessage2(outer));
2972
+ if (!a || !b) return true;
2973
+ if (a !== b) return false;
2974
+ const at = errorLocation(inner);
2975
+ const bt = errorLocation(outer);
2976
+ return !at || !bt || sameCodeLocation(at, bt);
2977
+ }
2978
+ function lastByPhase(indices, phases) {
2979
+ for (const phase of ["body", "setup", "teardown"]) {
2980
+ const inPhase = indices.filter((i) => phases[i] === phase);
2981
+ if (inPhase.length > 0) return inPhase[inPhase.length - 1];
2982
+ }
2983
+ return null;
2984
+ }
2985
+ function failingChainEnds(steps, parents) {
2939
2986
  const candidates = [];
2940
2987
  steps.forEach((step, i) => {
2941
- if (isFailedStep(step) && !isCaptureStep(step)) candidates.push(i);
2988
+ if (isFailedStep(step) && !isCaptureStep(step) && !isRecoveredStep(step)) candidates.push(i);
2942
2989
  });
2943
- if (candidates.length === 0) return null;
2944
- let current = candidates[0];
2945
- for (; ; ) {
2946
- const inner = candidates.find((c) => c > current && isDescendant(c, current, parents));
2947
- if (inner === void 0) return current;
2948
- current = inner;
2990
+ const isCandidate = new Set(candidates);
2991
+ const hasFailedAncestor = (i) => {
2992
+ for (let p = parents[i] ?? -1; p !== -1; p = parents[p] ?? -1) if (isCandidate.has(p)) return true;
2993
+ return false;
2994
+ };
2995
+ return candidates.filter((root) => !hasFailedAncestor(root)).map((root) => {
2996
+ let current = root;
2997
+ for (; ; ) {
2998
+ const inner = candidates.filter((c) => c > current && isDescendant(c, current, parents));
2999
+ const carrying = inner.filter((c) => sameError(steps[c], steps[current]));
3000
+ if (carrying.length === 0) return current;
3001
+ current = carrying[carrying.length - 1];
3002
+ }
3003
+ });
3004
+ }
3005
+ function failingStepIndex(steps, parents = stepParents(steps), error) {
3006
+ const ends = failingChainEnds(steps, parents);
3007
+ if (ends.length === 0) return null;
3008
+ const phases = stepPhases(steps, parents);
3009
+ if (error) {
3010
+ const heads = errorHeads(error);
3011
+ const headOf = (i) => firstLine(errorMessage2(steps[i]));
3012
+ const frame = extractTopFrame(error);
3013
+ if (frame) {
3014
+ const thrownThere = ends.filter(
3015
+ (i) => heads.includes(headOf(i)) && sameCodeLocation(errorLocation(steps[i]) || str(steps[i].location), frame)
3016
+ );
3017
+ const located = lastByPhase(thrownThere, phases);
3018
+ if (located !== null) return located;
3019
+ }
3020
+ for (const head of heads) {
3021
+ const matching = lastByPhase(
3022
+ ends.filter((i) => headOf(i) === head),
3023
+ phases
3024
+ );
3025
+ if (matching !== null) return matching;
3026
+ }
2949
3027
  }
3028
+ return lastByPhase(ends, phases) ?? ends[ends.length - 1];
2950
3029
  }
2951
3030
  function stepHookName(step) {
2952
3031
  const title = str(step.title).trim();
@@ -2959,11 +3038,8 @@ function stepHookName(step) {
2959
3038
  if (category === "hook" && title && !isPhaseContainer(step)) return `hook "${title}"`;
2960
3039
  return null;
2961
3040
  }
2962
- function firstLine(message) {
2963
- return stripAnsi(str(message)).split("\n")[0].trim();
2964
- }
2965
3041
  function failureHookContext(steps, error, parents = stepParents(steps)) {
2966
- const index = failingStepIndex(steps, parents);
3042
+ const index = failingStepIndex(steps, parents, error);
2967
3043
  if (index === null) return null;
2968
3044
  const phase = stepPhases(steps, parents)[index];
2969
3045
  if (phase === "body") return null;
@@ -3029,6 +3105,28 @@ function normalizeStepParams(raw) {
3029
3105
  }
3030
3106
  return count > 0 ? out : void 0;
3031
3107
  }
3108
+ function locationText(location) {
3109
+ if (!location || typeof location.file !== "string" || !location.file) return void 0;
3110
+ return `${location.file}:${location.line}:${location.column}`;
3111
+ }
3112
+ function markRecoveredSteps(steps, errors) {
3113
+ const fatal = errors.map((error) => ({
3114
+ head: firstErrorLine(error.message),
3115
+ location: locationText(error.location)
3116
+ }));
3117
+ for (const step of steps) {
3118
+ if (!step.error) continue;
3119
+ const head = firstErrorLine(step.error.message);
3120
+ const thrownAt = step.error.location;
3121
+ const isFatal = fatal.some(
3122
+ (error) => error.head === head && (!error.location || !thrownAt || sameCodeLocation(error.location, thrownAt))
3123
+ );
3124
+ if (!isFatal) step.recovered = true;
3125
+ }
3126
+ }
3127
+ function firstErrorLine(message) {
3128
+ return stripAnsi(typeof message === "string" ? message : "").split("\n")[0].trim();
3129
+ }
3032
3130
  function flattenSteps(steps, depth = 0) {
3033
3131
  const result = [];
3034
3132
  for (const step of steps) {
@@ -3043,17 +3141,21 @@ function flattenSteps(steps, depth = 0) {
3043
3141
  if (params) flat.params = params;
3044
3142
  if (step.error?.message) {
3045
3143
  flat.error = { message: step.error.message };
3144
+ const thrownAt = locationText(step.error.location);
3145
+ if (thrownAt) flat.error.location = thrownAt;
3046
3146
  flat.failed = true;
3047
3147
  }
3048
- if (step.location) flat.location = `${step.location.file}:${step.location.line}:${step.location.column}`;
3148
+ const location = locationText(step.location);
3149
+ if (location) flat.location = location;
3049
3150
  if (step.startTime) flat.startTime = step.startTime instanceof Date ? step.startTime.getTime() : step.startTime;
3050
3151
  result.push(flat);
3051
3152
  if (step.steps?.length > 0) result.push(...flattenSteps(step.steps, depth + 1));
3052
3153
  }
3053
3154
  return result;
3054
3155
  }
3055
- function collectStepMetrics(steps) {
3156
+ function collectStepMetrics(steps, errors) {
3056
3157
  const flatSteps = flattenSteps(steps);
3158
+ if (errors) markRecoveredSteps(flatSteps, errors);
3057
3159
  const totalStepDuration = steps.reduce((sum, s) => sum + (s.duration || 0), 0);
3058
3160
  let slowestStep = null;
3059
3161
  for (const s of flatSteps) {
@@ -3363,6 +3465,20 @@ function readSelectionStamp(env = process.env) {
3363
3465
  return { key, version, resolvedHash, resolvedCount };
3364
3466
  }
3365
3467
 
3468
+ // src/internal/support/shard-info.ts
3469
+ function parseShardSpec(raw) {
3470
+ const match = raw?.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
3471
+ if (!match) return null;
3472
+ const current = Number(match[1]);
3473
+ const total = Number(match[2]);
3474
+ return current >= 1 && current <= total && total > 1 ? { current, total } : null;
3475
+ }
3476
+ function resolveShardInfo(config, env = process.env) {
3477
+ const pwShard = config.shard;
3478
+ if (pwShard && pwShard.total > 1) return { current: pwShard.current, total: pwShard.total };
3479
+ return parseShardSpec(env[PIWI_SHARD_ENV]);
3480
+ }
3481
+
3366
3482
  // src/internal/support/run-mode.ts
3367
3483
  var PW_UI_FLAGS = ["--ui", "--ui-host", "--ui-port"];
3368
3484
  function isUiMode(argv = process.argv) {
@@ -3554,9 +3670,7 @@ function createGlobalSetup(options, userSetup) {
3554
3670
  try {
3555
3671
  const auth = await httpClient.resolveAuth(opts);
3556
3672
  const runLabel = opts.runLabel || detectCiRunLabel();
3557
- const pwShard = config.shard;
3558
- const shardIndex = pwShard?.current;
3559
- const shardTotal = pwShard?.total;
3673
+ const shard = resolveShardInfo(config);
3560
3674
  const response = await httpClient.postJSON(
3561
3675
  "/api/test-runs/setup",
3562
3676
  {
@@ -3567,8 +3681,8 @@ function createGlobalSetup(options, userSetup) {
3567
3681
  keep: opts.keep === true,
3568
3682
  startTime: (/* @__PURE__ */ new Date()).toISOString(),
3569
3683
  instanceId: computeInstanceId(opts.projectName, runLabel),
3570
- shardIndex,
3571
- shardTotal
3684
+ shardIndex: shard?.current,
3685
+ shardTotal: shard?.total
3572
3686
  },
3573
3687
  auth
3574
3688
  );
@@ -3745,6 +3859,7 @@ var LONG_HEX_RE2 = /\b[0-9a-f]{8,}\b/gi;
3745
3859
  var SHORT_HEX_RE = /\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{6,7}\b/gi;
3746
3860
  var URL_RE2 = /\bhttps?:\/\/[^\s'"`)]+/gi;
3747
3861
  var EMAIL_RE = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/gi;
3862
+ var SELECTOR_OPTION_RE = /\b(name|hasText|hasNotText|has|placeholder|label|title|alt|exact)\s*:\s*(['"`])(?:\\.|(?!\2)[\s\S])*?\2/gi;
3748
3863
  function classifyError(text) {
3749
3864
  if (/strict mode violation/i.test(text)) return "strict-mode";
3750
3865
  if (/\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)|\.toHave|\.toBe|\.toContain|\.toEqual/.test(text))
@@ -3758,10 +3873,13 @@ function classifyError(text) {
3758
3873
  function maskVolatile(text) {
3759
3874
  return text.replace(/^(\s*(?:Received|Expected)[^:\n]*:).*$/gm, "$1 <VALUE>").replace(URL_RE2, "<URL>").replace(EMAIL_RE, "<EMAIL>").replace(UUID_RE, "<UUID>").replace(LONG_HEX_RE2, "<HASH>").replace(SHORT_HEX_RE, "<HASH>").replace(/([A-Za-z])?(\d+)/g, (whole, letter) => letter ? whole : "<N>");
3760
3875
  }
3876
+ function maskSelectorOptions(text) {
3877
+ return text.replace(SELECTOR_OPTION_RE, (_m, key) => `${key}: <STR>`);
3878
+ }
3761
3879
  function extractErrorSignature(rawError) {
3762
3880
  const text = stripAnsi(rawError);
3763
3881
  const errorType = classifyError(text);
3764
- const normalizedMessage = maskVolatile(extractMessageHead(text));
3882
+ const normalizedMessage = maskVolatile(maskSelectorOptions(extractMessageHead(text)));
3765
3883
  const selector = extractSelector(text);
3766
3884
  const topFrameFile = extractTopFrameFile(text);
3767
3885
  const signature = (normalizedMessage.split("\n")[0] || "").slice(0, 200) || "Unknown error";
@@ -5466,10 +5584,10 @@ function describeFailureText(raw, ctx) {
5466
5584
  if (!raw || !raw.trim()) return null;
5467
5585
  return describeFailure(parsePlaywrightError(raw, { stepParams: ctx?.stepParams }), ctx);
5468
5586
  }
5469
- function headlineStep(steps) {
5587
+ function headlineStep(steps, error) {
5470
5588
  if (!steps || steps.length === 0) return null;
5471
5589
  const parents = stepParents(steps);
5472
- const failed = failingStepIndex(steps, parents);
5590
+ const failed = failingStepIndex(steps, parents, error);
5473
5591
  if (failed !== null) return steps[failed];
5474
5592
  const phases = stepPhases(steps, parents);
5475
5593
  for (let i = steps.length - 1; i >= 0; i--) {
@@ -5478,17 +5596,17 @@ function headlineStep(steps) {
5478
5596
  }
5479
5597
  return steps[steps.length - 1];
5480
5598
  }
5481
- function lastStepTitle(steps) {
5482
- const title = headlineStep(steps)?.title;
5599
+ function lastStepTitle(steps, error) {
5600
+ const title = headlineStep(steps, error)?.title;
5483
5601
  return typeof title === "string" ? title : null;
5484
5602
  }
5485
- function failingStepParams(steps) {
5486
- return headlineStep(steps)?.params ?? null;
5603
+ function failingStepParams(steps, error) {
5604
+ return headlineStep(steps, error)?.params ?? null;
5487
5605
  }
5488
5606
  function stepHeadlineContext(steps, error) {
5489
5607
  return {
5490
- lastStepTitle: lastStepTitle(steps),
5491
- stepParams: failingStepParams(steps),
5608
+ lastStepTitle: lastStepTitle(steps, error),
5609
+ stepParams: failingStepParams(steps, error),
5492
5610
  failedIn: steps && steps.length > 0 ? failureHookContext(steps, error ?? null) : null
5493
5611
  };
5494
5612
  }
@@ -5670,9 +5788,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5670
5788
  this.metadata = flakeRunMetadata(stamp, this.metadata);
5671
5789
  }
5672
5790
  this.plannedTests = suite.allTests();
5673
- const pwShard = config.shard;
5674
- if (pwShard?.total && pwShard.total > 1) {
5675
- this.shardInfo = { current: pwShard.current, total: pwShard.total };
5791
+ this.shardInfo = resolveShardInfo(config);
5792
+ if (this.shardInfo) {
5676
5793
  this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
5677
5794
  }
5678
5795
  this.streamManager?.start(
@@ -5827,7 +5944,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
5827
5944
  if (frames.length) testCase.testSourceFrames = frames;
5828
5945
  }
5829
5946
  if (this.options.collectPerformanceMetrics && result.steps?.length > 0) {
5830
- testCase.performanceMetrics = collectStepMetrics(result.steps);
5947
+ testCase.performanceMetrics = collectStepMetrics(result.steps, result.errors);
5831
5948
  const stepEvents = extractTestStepEvents(result.steps, result.startTime);
5832
5949
  const waitEvents = extractWaitEvents(result.steps);
5833
5950
  const allEvents = [...stepEvents, ...waitEvents];
@@ -9938,15 +10055,15 @@ function entryPath(params) {
9938
10055
  const name = `${slug(params.testTitle)}.${slug(params.template)}.${hash}.json`;
9939
10056
  return path25.join(entryDir(params.specFile, params.dir), name);
9940
10057
  }
9941
- function parseLocation(location) {
10058
+ function parseLocation2(location) {
9942
10059
  const match = /:(\d+):(\d+)$/.exec(location);
9943
10060
  if (!match) return null;
9944
10061
  return { line: Number(match[1]), col: Number(match[2]) };
9945
10062
  }
9946
10063
  function orderByPosition(locations) {
9947
10064
  return [...new Set(locations)].sort((a, b) => {
9948
- const pa = parseLocation(a);
9949
- const pb = parseLocation(b);
10065
+ const pa = parseLocation2(a);
10066
+ const pb = parseLocation2(b);
9950
10067
  if (!pa || !pb) return a < b ? -1 : a > b ? 1 : 0;
9951
10068
  return pa.line - pb.line || pa.col - pb.col;
9952
10069
  });
@@ -2813,6 +2813,7 @@ var LONG_HEX_RE = /\b[0-9a-f]{8,}\b/gi;
2813
2813
  var SHORT_HEX_RE = /\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{6,7}\b/gi;
2814
2814
  var URL_RE = /\bhttps?:\/\/[^\s'"`)]+/gi;
2815
2815
  var EMAIL_RE = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/gi;
2816
+ var SELECTOR_OPTION_RE = /\b(name|hasText|hasNotText|has|placeholder|label|title|alt|exact)\s*:\s*(['"`])(?:\\.|(?!\2)[\s\S])*?\2/gi;
2816
2817
  function classifyError(text) {
2817
2818
  if (/strict mode violation/i.test(text)) return "strict-mode";
2818
2819
  if (/\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)|\.toHave|\.toBe|\.toContain|\.toEqual/.test(text))
@@ -2826,10 +2827,13 @@ function classifyError(text) {
2826
2827
  function maskVolatile(text) {
2827
2828
  return text.replace(/^(\s*(?:Received|Expected)[^:\n]*:).*$/gm, "$1 <VALUE>").replace(URL_RE, "<URL>").replace(EMAIL_RE, "<EMAIL>").replace(UUID_RE, "<UUID>").replace(LONG_HEX_RE, "<HASH>").replace(SHORT_HEX_RE, "<HASH>").replace(/([A-Za-z])?(\d+)/g, (whole, letter) => letter ? whole : "<N>");
2828
2829
  }
2830
+ function maskSelectorOptions(text) {
2831
+ return text.replace(SELECTOR_OPTION_RE, (_m, key) => `${key}: <STR>`);
2832
+ }
2829
2833
  function extractErrorSignature(rawError) {
2830
2834
  const text = stripAnsi(rawError);
2831
2835
  const errorType = classifyError(text);
2832
- const normalizedMessage = maskVolatile(extractMessageHead(text));
2836
+ const normalizedMessage = maskVolatile(maskSelectorOptions(extractMessageHead(text)));
2833
2837
  const selector = extractSelector(text);
2834
2838
  const topFrameFile = extractTopFrameFile(text);
2835
2839
  const signature = (normalizedMessage.split("\n")[0] || "").slice(0, 200) || "Unknown error";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",
@@ -27,7 +27,7 @@ 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 packages/reporter/admin access, `set_cluster_status` marks the cluster resolved with a note so it drops off the triage queue.
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.
31
31
 
32
32
  ## Guardrails
33
33
 
@@ -47,7 +47,7 @@ change it to import from the fixtures file instead, e.g. `import { test, expect
47
47
 
48
48
  ## Authentication
49
49
 
50
- If the dashboard has auth enabled, runs need an API key. Have the user create one in the dashboard (**Settings → Users → API keys**; keys start with `pd_`), then put it in `.env` as `PIWI_API_KEY=pd_...` and make sure `.env` is git-ignored. Never hardcode a key into `playwright.config.ts` or commit it. In CI, pass it as a secret (`PIWI_API_KEY`).
50
+ If the dashboard has auth enabled, runs need an API key. Have the user create one in the dashboard (**Settings → Account → API keys**; keys start with `pd_`), then put it in `.env` as `PIWI_API_KEY=pd_...` and make sure `.env` is git-ignored. Never hardcode a key into `playwright.config.ts` or commit it. In CI, pass it as a secret (`PIWI_API_KEY`).
51
51
 
52
52
  ## Verify it worked
53
53
 
@@ -1,36 +1,51 @@
1
1
  ---
2
2
  name: stabilize-flaky-tests
3
- description: Find the flakiest Playwright tests from Piwi's flaky analysis and fix the root cause of the intermittency, ranked by impact. Use when the user asks to "fix flaky tests", "reduce flakiness", "why is this test flaky", or wants to clean up an unreliable suite.
3
+ description: Find the flakiest Playwright tests from Piwi's flaky analysis, make each one fail on demand with Piwi's Flake Lab, fix what the reproducing condition points at, and prove the fix under that same condition. Use when the user asks to "fix flaky tests", "reduce flakiness", "why is this test flaky", or wants to clean up an unreliable suite.
4
4
  ---
5
5
 
6
6
  # Stabilize flaky tests with Piwi
7
7
 
8
- Piwi scores every test's flakiness over its whole run history and ranks it by impact, so you spend effort on the tests that actually cost the team — not whichever one failed most recently. This skill picks the worst offenders, fixes the *cause* of the intermittency, and confirms the score improves.
8
+ Piwi scores every test's flakiness over its whole run history and ranks it by impact, so you spend effort on the tests that actually cost the team — not whichever one failed most recently. For each test it also ranks **suspects** from that history (a route that is slower when it fails, a test running alongside, load), and its **Flake Lab** replays each suspect as a condition next to a control until the failure reproduces. A reproduced condition proves the cause, and the same condition later proves the fix. This skill follows that loop: rank, reproduce, fix, verify.
9
9
 
10
10
  ## How you reach Piwi
11
11
 
12
- Prefer the **Piwi MCP server** if it is connected (`list_flaky_tests`, `get_test_stability_trend`, `get_test_case`, `get_spec_health`). Otherwise use the dashboard's **Flaky** tab for the project and work from what it shows plus the repo.
12
+ Prefer the **Piwi MCP server** if it is connected (`list_flaky_tests`, `get_flake_profile`, `plan_flake_experiment`, `get_test_case`, `get_test_stability_trend`). Otherwise use the dashboard's **Flaky** tab and the test's **Flakiness** tab, and work from what they show plus the repo. The lab itself is a command you run from the project root: `npx @piwitests/reporter flake`. It needs the Piwi capture fixtures in the specs (`piwiFixtures`) and a dashboard URL and reporter API key (`PIWI_DASHBOARD_URL`, `PIWI_API_KEY`, or the project's `.env`).
13
13
 
14
14
  ## Steps
15
15
 
16
- 1. **Rank the flaky tests.** Call `list_flaky_tests` for the project. It returns each test's flaky score, impact ranking, and a **root-cause category**. Take the top few by impact — do not try to fix the whole list at once.
16
+ 1. **Rank the flaky tests.** Call `list_flaky_tests` for the project and take the top few by impact. Do not try to fix the whole list at once.
17
17
 
18
- 2. **Understand one test's pattern.** For a chosen test, call `get_test_stability_trend` (is it getting worse, or already stable again?) and `get_test_case` (recent pass/fail history). Read a representative failing execution (`get_test_run_case` / `explain_failure`) for the error, steps, console, and network at the moment it flaked.
18
+ 2. **Read the suspects.** For a chosen test, call `get_flake_profile`. Each suspect carries raw counts ("slow in 7 of 8 failures and 3 of 44 passes"), the condition that would test it, and, once the lab has run, its latest result (`lab`). `experiments` lists what the lab already found; if an experiment reproduced the test, skip to step 4. Read one failing execution (`get_test_run_case` / `explain_failure`) for the error at the moment it flaked.
19
19
 
20
- 3. **Identify the cause.** Common categories and their fixes:
21
- - **Timing / race** — replace fixed waits and manual sleeps with Playwright web-first assertions (`await expect(locator).toBeVisible()`), await the network/UI state the test depends on, not a timeout.
22
- - **Locator instability** — a selector that resolves ambiguously or intermittently; heal it (see the `apply-locator-healing` skill) or scope it.
23
- - **Test-order / shared state** — leaking storage, cookies, or a shared backend row; isolate setup, use a fresh context, or a unique fixture per test.
24
- - **Slow / unreliable endpoint** — check `get_network_requests`; if a real endpoint is intermittently slow or 500s, that is a product bug, not a test bug — report it rather than papering over it with retries.
20
+ 3. **Reproduce it.** Run the lab on the test (`plan_flake_experiment` gives the exact commands and the arms it will run, with an estimate):
25
21
 
26
- 4. **Fix the root cause.** Make the change in the spec (or the app, when the flake is a real defect). Do **not** "fix" flakiness by adding retries or increasing timeouts — that hides it; the goal is a test that passes deterministically.
22
+ ```bash
23
+ npx @piwitests/reporter flake <testCaseId>
24
+ ```
27
25
 
28
- 5. **Verify.** Re-run the test several times to shake out intermittency: `npx playwright test <file> --repeat-each=5` (raise the count for stubborn ones). It should pass every time. Over the next runs, confirm the flaky score falls in the dashboard (`get_test_stability_trend`).
26
+ It runs a control, then one arm per suspect, retries off, and counts only failures with the same error as in CI. Exit code `0` means an arm reproduced the failure ("reproduced by delaying GET /api/cart to 1.8 s: 3/4 against 0/10, p = 0.011"); `1` means nothing did; `2` is an error (read its message: often the fixtures are not in use). Use `--suspect <n>` to test one suspect, `--all` to also try every condition at once. If the output warns that this checkout differs from the commit of the failures, say so in your report.
29
27
 
30
- 6. **Report.** For each test you touched: the root-cause category, the change, and the repeat-run result. List any remaining high-impact flaky tests so the user can decide whether to continue.
28
+ If nothing reproduces, do not guess a fix from the suspects alone. Report the arms and their counts, and fall back to reading the failure and the attempts diff.
29
+
30
+ 4. **Fix what the reproducing condition points at.** The condition names the cause:
31
+ - **A delayed route** — the test (or the app) acts before that response arrives. Wait for the response or for the UI state it produces (`await page.waitForResponse(...)`, `await expect(locator).toHaveText(...)`), not for a timeout.
32
+ - **A failed route** — the app does not handle that error. If the endpoint really fails in CI, that is a product finding: report it rather than hiding it in the test.
33
+ - **Another test alongside or just before** — shared state: a backend row, storage, a fixture both use. Isolate it (unique data per test, a fresh context, its own fixture).
34
+ - **CPU load** — a race the load exposes; the fix is the same as a delayed route: await the state the test depends on.
35
+ Make the change in the spec, or in the app when the flake is a real defect.
36
+
37
+ 5. **Verify the fix under the same condition.** Run:
38
+
39
+ ```bash
40
+ npx @piwitests/reporter flake verify <testCaseId>
41
+ ```
42
+
43
+ It reruns the arm that reproduced the failure, and its control, for enough runs to say the fix holds at the rate it failed. Exit code `0` means the fix held; `1` means it still fails (or too few runs passed to say). "It passed ten times in a row" is not a verification; this is.
44
+
45
+ 6. **Report.** For each test you touched: the reproducing condition and its counts, the change, and the verify result. List any remaining high-impact flaky tests, and any test the lab could not reproduce, so the user can decide whether to continue.
31
46
 
32
47
  ## Guardrails
33
48
 
34
- - Fix the cause, never the symptom — no blanket `test.retry`, no bumped global timeout, no `waitForTimeout` sprinkles.
49
+ - Fix the cause, never the symptom — no blanket `test.retry`, no bumped global or per-test timeout, no `waitForTimeout` sprinkles. A longer timeout can make `flake verify` pass without fixing anything; do not do it.
35
50
  - A test that flakes because a *real* endpoint is unreliable is a product finding; surface it instead of hiding it in the test.
36
- - Stabilize a few high-impact tests well rather than lightly touching many. Prove each one with repeated runs before moving on.
51
+ - Stabilize a few high-impact tests well rather than lightly touching many. Prove each one with `piwi flake verify` before moving on.