@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.
- package/README.md +3 -3
- package/dist/cli/index.js +1271 -465
- package/dist/global-setup-module.js +18 -5
- package/dist/index.js +152 -35
- package/dist/internal/capture/capture-fixtures.js +5 -1
- package/package.json +1 -1
- package/templates/skills/investigate-failure/SKILL.md +1 -1
- package/templates/skills/setup-piwi/SKILL.md +1 -1
- package/templates/skills/stabilize-flaky-tests/SKILL.md +30 -15
|
@@ -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
|
|
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
|
|
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
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5674
|
-
if (
|
|
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
|
|
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 =
|
|
9949
|
-
const pb =
|
|
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.
|
|
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
|
|
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 →
|
|
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
|
|
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.
|
|
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`, `
|
|
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
|
|
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. **
|
|
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. **
|
|
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
|
-
|
|
22
|
+
```bash
|
|
23
|
+
npx @piwitests/reporter flake <testCaseId>
|
|
24
|
+
```
|
|
27
25
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
51
|
+
- Stabilize a few high-impact tests well rather than lightly touching many. Prove each one with `piwi flake verify` before moving on.
|