@piwitests/reporter 0.41.0 → 0.43.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 +1275 -469
- package/dist/global-setup-module.js +68 -24
- package/dist/index.d.ts +18 -5
- package/dist/index.js +418 -103
- package/dist/internal/capture/capture-fixtures.js +50 -7
- package/dist/internal/capture/locator-healing.d.ts +1 -7
- package/dist/internal/capture/locator-healing.js +0 -2
- package/dist/internal/capture/pick-on-failure.js +3 -3
- 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
|
@@ -399,7 +399,7 @@ function installPickerOverlay(arg) {
|
|
|
399
399
|
banner.style.cssText = `position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:${Z + 2};background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;border:1px solid #312e81;padding:10px 16px;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.5);max-width:min(680px,86vw);`;
|
|
400
400
|
const hlTokens = (expr) => {
|
|
401
401
|
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
402
|
-
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
402
|
+
const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
403
403
|
let html = "";
|
|
404
404
|
let last = 0;
|
|
405
405
|
let m;
|
|
@@ -942,7 +942,7 @@ function showPickerChoices(arg) {
|
|
|
942
942
|
panel.style.cssText = "background:#111827;color:#f9fafb;border-radius:10px;padding:20px;max-width:640px;width:90vw;max-height:70vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,.5);";
|
|
943
943
|
const hlLocator = (expr) => {
|
|
944
944
|
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
945
|
-
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
945
|
+
const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
946
946
|
let html = "";
|
|
947
947
|
let last = 0;
|
|
948
948
|
let m;
|
|
@@ -2045,6 +2045,7 @@ var DEFAULTS = {
|
|
|
2045
2045
|
streamingBatchSize: 5,
|
|
2046
2046
|
streamingBatchDelay: 2e3,
|
|
2047
2047
|
maxStreamBufferBytes: 100 * 1024 * 1024,
|
|
2048
|
+
submitTimeout: 15 * 60 * 1e3,
|
|
2048
2049
|
failOnFlakyTests: false,
|
|
2049
2050
|
username: null,
|
|
2050
2051
|
password: null,
|
|
@@ -2066,6 +2067,7 @@ var PIWI_ENV_KEYS = {
|
|
|
2066
2067
|
streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
|
|
2067
2068
|
streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
|
|
2068
2069
|
maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
|
|
2070
|
+
submitTimeout: "PIWI_SUBMIT_TIMEOUT",
|
|
2069
2071
|
liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
|
|
2070
2072
|
failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
|
|
2071
2073
|
uploadTraces: "PIWI_UPLOAD_TRACES",
|
|
@@ -2115,6 +2117,7 @@ var ENV_FALLBACK_SPECS = [
|
|
|
2115
2117
|
{ option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
|
|
2116
2118
|
{ option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
|
|
2117
2119
|
{ option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
|
|
2120
|
+
{ option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
|
|
2118
2121
|
{ option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
|
|
2119
2122
|
{ option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
|
|
2120
2123
|
{ option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
|
|
@@ -2791,15 +2794,19 @@ function extractSelector(text) {
|
|
|
2791
2794
|
return text.slice(start, start + 80);
|
|
2792
2795
|
}
|
|
2793
2796
|
var LEAF_METHODS = new Set(LOCATOR_BUILDER_METHODS);
|
|
2794
|
-
function
|
|
2797
|
+
function extractStackFrames(text) {
|
|
2795
2798
|
const frameRe = /^\s+at (?:.*? \()?([^()\s][^()]*?):(\d+):(\d+)\)?\s*$/gm;
|
|
2799
|
+
const frames = [];
|
|
2796
2800
|
let m;
|
|
2797
2801
|
while ((m = frameRe.exec(text)) !== null) {
|
|
2798
2802
|
const file = m[1].replace(/\\/g, "/");
|
|
2799
2803
|
if (file.includes("node_modules") || file.startsWith("node:")) continue;
|
|
2800
|
-
|
|
2804
|
+
frames.push({ file, line: Number(m[2]), column: Number(m[3]) });
|
|
2801
2805
|
}
|
|
2802
|
-
return
|
|
2806
|
+
return frames;
|
|
2807
|
+
}
|
|
2808
|
+
function extractTopFrame(text) {
|
|
2809
|
+
return extractStackFrames(text)[0] ?? null;
|
|
2803
2810
|
}
|
|
2804
2811
|
function extractTopFrameFile(text) {
|
|
2805
2812
|
return extractTopFrame(text)?.file ?? null;
|
|
@@ -2813,6 +2820,42 @@ var LONG_HEX_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
|
2813
2820
|
var SHORT_HEX_RE = /\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{6,7}\b/gi;
|
|
2814
2821
|
var URL_RE = /\bhttps?:\/\/[^\s'"`)]+/gi;
|
|
2815
2822
|
var EMAIL_RE = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/gi;
|
|
2823
|
+
var SELECTOR_OPTION_KEY_RE = /\b(name|hasText|hasNotText|has|placeholder|label|title|alt|exact)\s*:\s*(['"`])/gi;
|
|
2824
|
+
var LINE_TERMINATORS = /* @__PURE__ */ new Set(["\n", "\r", "\u2028", "\u2029"]);
|
|
2825
|
+
function closingQuoteIndex(text, quote) {
|
|
2826
|
+
const close = new Int32Array(text.length + 1).fill(-1);
|
|
2827
|
+
for (let p = text.length - 1; p >= 0; p--) {
|
|
2828
|
+
const ch = text[p];
|
|
2829
|
+
if (ch === quote) {
|
|
2830
|
+
close[p] = p;
|
|
2831
|
+
continue;
|
|
2832
|
+
}
|
|
2833
|
+
const escaped = ch === "\\" && p + 1 < text.length && !LINE_TERMINATORS.has(text[p + 1]) ? close[p + 2] : -1;
|
|
2834
|
+
close[p] = escaped !== -1 ? escaped : close[p + 1];
|
|
2835
|
+
}
|
|
2836
|
+
return close;
|
|
2837
|
+
}
|
|
2838
|
+
function maskSelectorOptions(text) {
|
|
2839
|
+
const closers = /* @__PURE__ */ new Map();
|
|
2840
|
+
let out = "";
|
|
2841
|
+
let copied = 0;
|
|
2842
|
+
SELECTOR_OPTION_KEY_RE.lastIndex = 0;
|
|
2843
|
+
let match;
|
|
2844
|
+
while (match = SELECTOR_OPTION_KEY_RE.exec(text)) {
|
|
2845
|
+
const quote = match[2];
|
|
2846
|
+
let close = closers.get(quote);
|
|
2847
|
+
if (!close) closers.set(quote, close = closingQuoteIndex(text, quote));
|
|
2848
|
+
const end = close[SELECTOR_OPTION_KEY_RE.lastIndex];
|
|
2849
|
+
if (end === -1) {
|
|
2850
|
+
SELECTOR_OPTION_KEY_RE.lastIndex = match.index + 1;
|
|
2851
|
+
continue;
|
|
2852
|
+
}
|
|
2853
|
+
out += `${text.slice(copied, match.index)}${match[1]}: <STR>`;
|
|
2854
|
+
copied = end + 1;
|
|
2855
|
+
SELECTOR_OPTION_KEY_RE.lastIndex = copied;
|
|
2856
|
+
}
|
|
2857
|
+
return out + text.slice(copied);
|
|
2858
|
+
}
|
|
2816
2859
|
function classifyError(text) {
|
|
2817
2860
|
if (/strict mode violation/i.test(text)) return "strict-mode";
|
|
2818
2861
|
if (/\bexpect\(|\bexpect\.|Expected (?:string|substring|pattern|value)|\.toHave|\.toBe|\.toContain|\.toEqual/.test(text))
|
|
@@ -2829,7 +2872,7 @@ function maskVolatile(text) {
|
|
|
2829
2872
|
function extractErrorSignature(rawError) {
|
|
2830
2873
|
const text = stripAnsi(rawError);
|
|
2831
2874
|
const errorType = classifyError(text);
|
|
2832
|
-
const normalizedMessage = maskVolatile(extractMessageHead(text));
|
|
2875
|
+
const normalizedMessage = maskVolatile(maskSelectorOptions(extractMessageHead(text)));
|
|
2833
2876
|
const selector = extractSelector(text);
|
|
2834
2877
|
const topFrameFile = extractTopFrameFile(text);
|
|
2835
2878
|
const signature = (normalizedMessage.split("\n")[0] || "").slice(0, 200) || "Unknown error";
|
|
@@ -3212,7 +3255,7 @@ function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT
|
|
|
3212
3255
|
|
|
3213
3256
|
// src/internal/capture/pick-on-failure.ts
|
|
3214
3257
|
var path5 = __toESM(require("path"));
|
|
3215
|
-
var ANSI_RE2 =
|
|
3258
|
+
var ANSI_RE2 = /\x1b\[[0-9;]*m/g;
|
|
3216
3259
|
function parseLeafLocatorExpression(rawExpr) {
|
|
3217
3260
|
const leaf = parseLeafLocatorCall(rawExpr);
|
|
3218
3261
|
return leaf ? { method: leaf.method, args: locatorCallValues(leaf) } : null;
|
|
@@ -85,12 +85,6 @@ declare const EXPECT_CAPTURE_EXPRESSIONS: ReadonlySet<string>;
|
|
|
85
85
|
declare function extractAccessibleName(ariaSnapshot: string | null): string | null;
|
|
86
86
|
/** Tags whose accessible name comes from an associated `<label>`. */
|
|
87
87
|
declare const FORM_FIELD_TAGS: ReadonlySet<string>;
|
|
88
|
-
/**
|
|
89
|
-
* Roles whose accessible name is computed from the element's own content. For
|
|
90
|
-
* these, an element carrying neither `aria-label` nor `aria-labelledby` is
|
|
91
|
-
* named by its trimmed text — which the probe already returns.
|
|
92
|
-
*/
|
|
93
|
-
declare const NAME_FROM_CONTENT_ROLES: ReadonlySet<string>;
|
|
94
88
|
/** The subset of a probe result the accessible-name shortcut reads. */
|
|
95
89
|
interface NameSource {
|
|
96
90
|
tagName: string;
|
|
@@ -153,4 +147,4 @@ declare function suggestLocatorsFromAria(failed: FailedLocatorInfo, ariaSnapshot
|
|
|
153
147
|
*/
|
|
154
148
|
declare function captureCallerLocation(stack?: string): string | null;
|
|
155
149
|
|
|
156
|
-
export { ACTION_METHODS, CHAIN_METHODS, EXPECT_CAPTURE_EXPRESSIONS, EXPECT_METHOD, FORM_FIELD_TAGS, type FailedLocatorInfo, LOCATOR_CREATING_CHAINS, LOCATOR_METHODS, type LocatorSuggestion,
|
|
150
|
+
export { ACTION_METHODS, CHAIN_METHODS, EXPECT_CAPTURE_EXPRESSIONS, EXPECT_METHOD, FORM_FIELD_TAGS, type FailedLocatorInfo, LOCATOR_CREATING_CHAINS, LOCATOR_METHODS, type LocatorSuggestion, type NameSource, captureCallerLocation, dedupeSnapshotsByLocation, exactAccessibleName, extractAccessibleName, renderFailing, suggestLocatorsFromAria };
|
|
@@ -39,7 +39,6 @@ __export(locator_healing_exports, {
|
|
|
39
39
|
INPUT_TYPE_TO_ROLE: () => INPUT_TYPE_TO_ROLE,
|
|
40
40
|
LOCATOR_CREATING_CHAINS: () => LOCATOR_CREATING_CHAINS,
|
|
41
41
|
LOCATOR_METHODS: () => LOCATOR_METHODS,
|
|
42
|
-
NAME_FROM_CONTENT_ROLES: () => NAME_FROM_CONTENT_ROLES,
|
|
43
42
|
TAG_TO_ROLE: () => TAG_TO_ROLE,
|
|
44
43
|
approximateAccessibleName: () => approximateAccessibleName,
|
|
45
44
|
captureCallerLocation: () => captureCallerLocation,
|
|
@@ -760,7 +759,6 @@ function captureCallerLocation(stack = new Error().stack ?? "") {
|
|
|
760
759
|
INPUT_TYPE_TO_ROLE,
|
|
761
760
|
LOCATOR_CREATING_CHAINS,
|
|
762
761
|
LOCATOR_METHODS,
|
|
763
|
-
NAME_FROM_CONTENT_ROLES,
|
|
764
762
|
TAG_TO_ROLE,
|
|
765
763
|
approximateAccessibleName,
|
|
766
764
|
captureCallerLocation,
|
|
@@ -343,7 +343,7 @@ function installPickerOverlay(arg) {
|
|
|
343
343
|
banner.style.cssText = `position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:${Z + 2};background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;border:1px solid #312e81;padding:10px 16px;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.5);max-width:min(680px,86vw);`;
|
|
344
344
|
const hlTokens = (expr) => {
|
|
345
345
|
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
346
|
-
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
346
|
+
const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
347
347
|
let html = "";
|
|
348
348
|
let last = 0;
|
|
349
349
|
let m;
|
|
@@ -886,7 +886,7 @@ function showPickerChoices(arg) {
|
|
|
886
886
|
panel.style.cssText = "background:#111827;color:#f9fafb;border-radius:10px;padding:20px;max-width:640px;width:90vw;max-height:70vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,.5);";
|
|
887
887
|
const hlLocator = (expr) => {
|
|
888
888
|
const escHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
889
|
-
const re = /('(?:\\.|[^'])*'|"(?:\\.|[^"])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
889
|
+
const re = /('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)(?=\s*:)|(true|false|null|\d+)|([{}(),.])/g;
|
|
890
890
|
let html = "";
|
|
891
891
|
let last = 0;
|
|
892
892
|
let m;
|
|
@@ -1412,7 +1412,7 @@ function renderFailing(failed) {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
|
|
1414
1414
|
// src/internal/capture/pick-on-failure.ts
|
|
1415
|
-
var ANSI_RE =
|
|
1415
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
1416
1416
|
function parseLeafLocatorExpression(rawExpr) {
|
|
1417
1417
|
const leaf = parseLeafLocatorCall(rawExpr);
|
|
1418
1418
|
return leaf ? { method: leaf.method, args: locatorCallValues(leaf) } : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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.
|