@fixsense/cypress-plugin 0.2.0 → 0.2.4
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 +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -3
- package/dist/local.d.ts +62 -3
- package/dist/local.js +148 -9
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -55,9 +55,28 @@ actually wrong, so a green suite stays quiet.
|
|
|
55
55
|
On GitHub Actions the same table is written to the job summary, so it appears
|
|
56
56
|
on the run's own page instead of scrolling past in the log.
|
|
57
57
|
|
|
58
|
+
On GitHub Actions it also writes an annotation for each flaky or broken test,
|
|
59
|
+
so the finding shows up inline on the pull request, carrying how many of the
|
|
60
|
+
last runs the test misbehaved in.
|
|
61
|
+
|
|
58
62
|
`.fixsense/` is git-ignored for you: committing it would mean a diff on every
|
|
59
63
|
run and merge conflicts in a file nobody reads.
|
|
60
64
|
|
|
65
|
+
### Keeping the history in CI
|
|
66
|
+
|
|
67
|
+
Every CI job starts from a clean checkout, so the history is gone before the
|
|
68
|
+
next run reads it. The plugin says so in one line instead of staying silent.
|
|
69
|
+
On GitHub Actions, cache the directory between runs:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
- name: Keep the test history between runs
|
|
73
|
+
uses: actions/cache@v4
|
|
74
|
+
with:
|
|
75
|
+
path: .fixsense
|
|
76
|
+
key: fixsense-history-${{ github.run_id }}
|
|
77
|
+
restore-keys: fixsense-history-
|
|
78
|
+
```
|
|
79
|
+
|
|
61
80
|
### Why an account, then
|
|
62
81
|
|
|
63
82
|
The local history lives on one machine and dies with the CI container. The
|
package/dist/index.d.ts
CHANGED
|
@@ -40,4 +40,4 @@ export declare function registerFixSense(on: On, options?: FixSenseCypressOption
|
|
|
40
40
|
export declare function sendRun(tests: TestRecord[], startedAt: Date, options: FixSenseCypressOptions): Promise<void>;
|
|
41
41
|
export default registerFixSense;
|
|
42
42
|
export * from "./payload";
|
|
43
|
-
export { computeFlakiness, readHistory, recordAndReport, renderMarkdown, renderSummary, scoreHistory, HISTORY_DIR, HISTORY_FILE, MAX_RUNS, } from "./local";
|
|
43
|
+
export { computeFlakiness, readHistory, recordAndReport, renderMarkdown, renderSummary, renderAnnotations, scoreHistory, HISTORY_DIR, HISTORY_FILE, MAX_RUNS, } from "./local";
|
package/dist/index.js
CHANGED
|
@@ -23,12 +23,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
23
23
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
24
24
|
};
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.MAX_RUNS = exports.HISTORY_FILE = exports.HISTORY_DIR = exports.scoreHistory = exports.renderSummary = exports.renderMarkdown = exports.recordAndReport = exports.readHistory = exports.computeFlakiness = void 0;
|
|
26
|
+
exports.MAX_RUNS = exports.HISTORY_FILE = exports.HISTORY_DIR = exports.scoreHistory = exports.renderAnnotations = exports.renderSummary = exports.renderMarkdown = exports.recordAndReport = exports.readHistory = exports.computeFlakiness = void 0;
|
|
27
27
|
exports.registerFixSense = registerFixSense;
|
|
28
28
|
exports.sendRun = sendRun;
|
|
29
29
|
const payload_1 = require("./payload");
|
|
30
30
|
const local_1 = require("./local");
|
|
31
31
|
const DEFAULT_API_URL = "https://fix-sense.com";
|
|
32
|
+
const DOCS_URL = "https://fix-sense.com/docs/integrations/cypress-plugin#keeping-the-history-in-ci";
|
|
32
33
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
33
34
|
/**
|
|
34
35
|
* Register the plugin from `setupNodeEvents`.
|
|
@@ -60,16 +61,19 @@ async function sendRun(tests, startedAt, options) {
|
|
|
60
61
|
return;
|
|
61
62
|
// The local history first, because it needs no account and is the only
|
|
62
63
|
// part most first-time users will ever see.
|
|
64
|
+
const apiKey = options.apiKey ?? process.env.FIXSENSE_API_KEY;
|
|
63
65
|
if (options.local !== false) {
|
|
64
66
|
try {
|
|
65
|
-
(0, local_1.recordAndReport)(options.cwd ?? process.cwd(), tests)
|
|
67
|
+
(0, local_1.recordAndReport)(options.cwd ?? process.cwd(), tests, new Date(), {
|
|
68
|
+
connected: Boolean(apiKey),
|
|
69
|
+
docsUrl: DOCS_URL,
|
|
70
|
+
});
|
|
66
71
|
}
|
|
67
72
|
catch (error) {
|
|
68
73
|
const reason = error instanceof Error ? error.message : String(error);
|
|
69
74
|
console.error(`[fixsense] Local history not updated: ${reason}`);
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
|
-
const apiKey = options.apiKey ?? process.env.FIXSENSE_API_KEY;
|
|
73
77
|
// Silent without a key: a developer running Cypress locally has none and
|
|
74
78
|
// does not need telling on every run.
|
|
75
79
|
if (!apiKey)
|
|
@@ -119,6 +123,7 @@ Object.defineProperty(exports, "readHistory", { enumerable: true, get: function
|
|
|
119
123
|
Object.defineProperty(exports, "recordAndReport", { enumerable: true, get: function () { return local_2.recordAndReport; } });
|
|
120
124
|
Object.defineProperty(exports, "renderMarkdown", { enumerable: true, get: function () { return local_2.renderMarkdown; } });
|
|
121
125
|
Object.defineProperty(exports, "renderSummary", { enumerable: true, get: function () { return local_2.renderSummary; } });
|
|
126
|
+
Object.defineProperty(exports, "renderAnnotations", { enumerable: true, get: function () { return local_2.renderAnnotations; } });
|
|
122
127
|
Object.defineProperty(exports, "scoreHistory", { enumerable: true, get: function () { return local_2.scoreHistory; } });
|
|
123
128
|
Object.defineProperty(exports, "HISTORY_DIR", { enumerable: true, get: function () { return local_2.HISTORY_DIR; } });
|
|
124
129
|
Object.defineProperty(exports, "HISTORY_FILE", { enumerable: true, get: function () { return local_2.HISTORY_FILE; } });
|
package/dist/local.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type Outcome = "expected" | "unexpected" | "flaky" | "skipped";
|
|
|
20
20
|
export interface StoredTest {
|
|
21
21
|
title: string;
|
|
22
22
|
file: string | null;
|
|
23
|
+
/** Where the test is declared, so a GitHub annotation can point at it. */
|
|
24
|
+
line?: number | null;
|
|
23
25
|
outcome: Outcome;
|
|
24
26
|
}
|
|
25
27
|
export interface StoredRun {
|
|
@@ -32,6 +34,7 @@ export interface LocalHistory {
|
|
|
32
34
|
}
|
|
33
35
|
export declare const HISTORY_DIR = ".fixsense";
|
|
34
36
|
export declare const HISTORY_FILE = "history.json";
|
|
37
|
+
export declare const SITE_URL = "https://fix-sense.com";
|
|
35
38
|
/**
|
|
36
39
|
* How many runs are kept.
|
|
37
40
|
*
|
|
@@ -43,6 +46,10 @@ export declare const MAX_RUNS = 50;
|
|
|
43
46
|
export declare const MIN_RUNS_FOR_VERDICT = 5;
|
|
44
47
|
/** At or above this score a test is called flaky. */
|
|
45
48
|
export declare const FLAKY_SCORE_THRESHOLD = 70;
|
|
49
|
+
/** Runs the runner itself called flaky before that alone is a verdict. */
|
|
50
|
+
export declare const MIN_FLAKY_RUNS = 2;
|
|
51
|
+
/** And how often, so a long-settled test is not judged on old history. */
|
|
52
|
+
export declare const MIN_FLAKY_RATE = 0.2;
|
|
46
53
|
export type Verdict = "stable" | "broken" | "flaky" | "suspect";
|
|
47
54
|
export interface FlakinessReport {
|
|
48
55
|
score: number;
|
|
@@ -81,6 +88,8 @@ export declare function writeHistory(cwd: string, history: LocalHistory): void;
|
|
|
81
88
|
export interface TestVerdict extends FlakinessReport {
|
|
82
89
|
title: string;
|
|
83
90
|
file: string | null;
|
|
91
|
+
/** The line this test is declared on, when the runner reports it. */
|
|
92
|
+
line: number | null;
|
|
84
93
|
/** How many of the stored runs this test failed or needed a retry in. */
|
|
85
94
|
badRuns: number;
|
|
86
95
|
}
|
|
@@ -93,9 +102,9 @@ export declare function scoreHistory(history: LocalHistory): TestVerdict[];
|
|
|
93
102
|
* so this stays empty unless the history actually found something. "suspect"
|
|
94
103
|
* is deliberately not reported: it means the tool is not sure yet.
|
|
95
104
|
*/
|
|
96
|
-
export declare function renderSummary(verdicts: TestVerdict[], totalRuns: number): string;
|
|
105
|
+
export declare function renderSummary(verdicts: TestVerdict[], totalRuns: number, connected?: boolean): string;
|
|
97
106
|
/** The same summary as GitHub-flavoured Markdown, for the run's own page. */
|
|
98
|
-
export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: number): string;
|
|
107
|
+
export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: number, connected?: boolean): string;
|
|
99
108
|
/**
|
|
100
109
|
* Write the Markdown where GitHub Actions renders it on the run's page.
|
|
101
110
|
*
|
|
@@ -103,9 +112,59 @@ export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: numbe
|
|
|
103
112
|
* page it is the first thing a reviewer sees.
|
|
104
113
|
*/
|
|
105
114
|
export declare function writeStepSummary(markdown: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* GitHub workflow annotations for the tests that earned a verdict.
|
|
117
|
+
*
|
|
118
|
+
* This is the one thing a single-run flaky reporter does that we did not:
|
|
119
|
+
* `::warning file=…,line=…::` makes GitHub draw the finding inline on the exact
|
|
120
|
+
* line in the pull request's "Files changed" tab, not only in the job log.
|
|
121
|
+
* Ours carries what a single run cannot — how many of the last N runs the test
|
|
122
|
+
* misbehaved in — so the annotation is a measurement, not a one-off retry.
|
|
123
|
+
*
|
|
124
|
+
* A `flaky` verdict is a warning: the test is noise to quarantine. A `broken`
|
|
125
|
+
* verdict — fails every run — is an error: a real failure hiding behind a
|
|
126
|
+
* retry, and the louder level is the point. "suspect" is never annotated, for
|
|
127
|
+
* the same reason it is never summarised: the tool is not sure yet.
|
|
128
|
+
*/
|
|
129
|
+
export declare function renderAnnotations(verdicts: TestVerdict[], totalRuns: number): string[];
|
|
130
|
+
/** True when we are inside a GitHub Actions job, where annotations render. */
|
|
131
|
+
export declare function isGitHubActions(env: Record<string, string | undefined>): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Emit the annotations as workflow commands on stdout, where the runner reads
|
|
134
|
+
* them. Each must be alone on its line, so this writes them directly rather
|
|
135
|
+
* than folding them into the human summary.
|
|
136
|
+
*/
|
|
137
|
+
export declare function writeAnnotations(lines: string[]): void;
|
|
138
|
+
/**
|
|
139
|
+
* One entry per test per run, whatever the runner reported.
|
|
140
|
+
*
|
|
141
|
+
* A run that listed the same test twice would count as two runs for it, and
|
|
142
|
+
* a retried test would read as several times as flaky as it is. The last
|
|
143
|
+
* entry wins: for a runner that reports each attempt, it is the one that
|
|
144
|
+
* knows how the test finally ended.
|
|
145
|
+
*/
|
|
146
|
+
export declare function dedupe(tests: StoredTest[]): StoredTest[];
|
|
147
|
+
/** Every supported CI sets CI=true, except Azure Pipelines, which sets TF_BUILD. */
|
|
148
|
+
export declare function isCi(env: Record<string, string | undefined>): boolean;
|
|
149
|
+
/**
|
|
150
|
+
* Said when a CI job finds no earlier runs to compare with.
|
|
151
|
+
*
|
|
152
|
+
* A CI job starts from a clean checkout, so without a cache the history holds
|
|
153
|
+
* one run every time, never reaches a verdict, and the package stays silent
|
|
154
|
+
* forever — which reads as "it does nothing". One line saying why is the
|
|
155
|
+
* difference between a team fixing that and a team uninstalling.
|
|
156
|
+
*/
|
|
157
|
+
export declare function renderNoHistoryHint(docsUrl: string): string;
|
|
158
|
+
export interface ReportContext {
|
|
159
|
+
/** An API key is set, so the history is also kept by the service. */
|
|
160
|
+
connected?: boolean;
|
|
161
|
+
/** Where the package's own docs explain keeping the history in CI. */
|
|
162
|
+
docsUrl?: string;
|
|
163
|
+
env?: Record<string, string | undefined>;
|
|
164
|
+
}
|
|
106
165
|
/**
|
|
107
166
|
* Record this run and report what the history now says.
|
|
108
167
|
*
|
|
109
168
|
* Returns the text that was printed, which is what the tests assert on.
|
|
110
169
|
*/
|
|
111
|
-
export declare function recordAndReport(cwd: string, tests: StoredTest[], now?: Date): string;
|
|
170
|
+
export declare function recordAndReport(cwd: string, tests: StoredTest[], now?: Date, context?: ReportContext): string;
|
package/dist/local.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* asserts the two copies are the same file, so they cannot drift.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.FLAKY_SCORE_THRESHOLD = exports.MIN_RUNS_FOR_VERDICT = exports.MAX_RUNS = exports.HISTORY_FILE = exports.HISTORY_DIR = void 0;
|
|
21
|
+
exports.MIN_FLAKY_RATE = exports.MIN_FLAKY_RUNS = exports.FLAKY_SCORE_THRESHOLD = exports.MIN_RUNS_FOR_VERDICT = exports.MAX_RUNS = exports.SITE_URL = exports.HISTORY_FILE = exports.HISTORY_DIR = void 0;
|
|
22
22
|
exports.countTransitions = countTransitions;
|
|
23
23
|
exports.computeFlakiness = computeFlakiness;
|
|
24
24
|
exports.historyPath = historyPath;
|
|
@@ -30,11 +30,18 @@ exports.scoreHistory = scoreHistory;
|
|
|
30
30
|
exports.renderSummary = renderSummary;
|
|
31
31
|
exports.renderMarkdown = renderMarkdown;
|
|
32
32
|
exports.writeStepSummary = writeStepSummary;
|
|
33
|
+
exports.renderAnnotations = renderAnnotations;
|
|
34
|
+
exports.isGitHubActions = isGitHubActions;
|
|
35
|
+
exports.writeAnnotations = writeAnnotations;
|
|
36
|
+
exports.dedupe = dedupe;
|
|
37
|
+
exports.isCi = isCi;
|
|
38
|
+
exports.renderNoHistoryHint = renderNoHistoryHint;
|
|
33
39
|
exports.recordAndReport = recordAndReport;
|
|
34
40
|
const fs_1 = require("fs");
|
|
35
41
|
const path_1 = require("path");
|
|
36
42
|
exports.HISTORY_DIR = ".fixsense";
|
|
37
43
|
exports.HISTORY_FILE = "history.json";
|
|
44
|
+
exports.SITE_URL = "https://fix-sense.com";
|
|
38
45
|
/**
|
|
39
46
|
* How many runs are kept.
|
|
40
47
|
*
|
|
@@ -46,6 +53,10 @@ exports.MAX_RUNS = 50;
|
|
|
46
53
|
exports.MIN_RUNS_FOR_VERDICT = 5;
|
|
47
54
|
/** At or above this score a test is called flaky. */
|
|
48
55
|
exports.FLAKY_SCORE_THRESHOLD = 70;
|
|
56
|
+
/** Runs the runner itself called flaky before that alone is a verdict. */
|
|
57
|
+
exports.MIN_FLAKY_RUNS = 2;
|
|
58
|
+
/** And how often, so a long-settled test is not judged on old history. */
|
|
59
|
+
exports.MIN_FLAKY_RATE = 0.2;
|
|
49
60
|
function toPassFail(outcome) {
|
|
50
61
|
if (outcome === "skipped")
|
|
51
62
|
return null;
|
|
@@ -88,12 +99,19 @@ function computeFlakiness(history) {
|
|
|
88
99
|
const selfReportedFlakyRate = selfReportedFlaky / history.length;
|
|
89
100
|
const transitions = countTransitions(sequence);
|
|
90
101
|
const transitionRate = runs > 1 ? transitions / (runs - 1) : 0;
|
|
102
|
+
// The score says how bad; this says whether we are sure. A run the runner
|
|
103
|
+
// itself called flaky is direct evidence — the test failed and then passed
|
|
104
|
+
// inside one run, which nothing but flakiness explains. A test that did
|
|
105
|
+
// that in a fifth of its runs is flaky even though the score, weighted to
|
|
106
|
+
// rank the worst offenders first, leaves it below the threshold. Two such
|
|
107
|
+
// runs are required so a single retry on a bad afternoon is not a verdict.
|
|
108
|
+
const isDirectlyEvidencedFlaky = selfReportedFlaky >= exports.MIN_FLAKY_RUNS && selfReportedFlakyRate >= exports.MIN_FLAKY_RATE;
|
|
91
109
|
const score = Math.round(Math.min(100, selfReportedFlakyRate * 100 * 0.75 + transitionRate * 100 * 0.25));
|
|
92
110
|
let verdict;
|
|
93
111
|
if (runs < exports.MIN_RUNS_FOR_VERDICT) {
|
|
94
112
|
verdict = score > 0 ? "suspect" : "stable";
|
|
95
113
|
}
|
|
96
|
-
else if (score >= exports.FLAKY_SCORE_THRESHOLD) {
|
|
114
|
+
else if (score >= exports.FLAKY_SCORE_THRESHOLD || isDirectlyEvidencedFlaky) {
|
|
97
115
|
verdict = "flaky";
|
|
98
116
|
}
|
|
99
117
|
else if (failureRate >= 0.9 && transitionRate <= 0.1 && selfReportedFlakyRate <= 0.1) {
|
|
@@ -179,7 +197,11 @@ function scoreHistory(history) {
|
|
|
179
197
|
for (const run of history.runs) {
|
|
180
198
|
for (const test of run.tests ?? []) {
|
|
181
199
|
const key = `${test.file ?? ""}\u0000${test.title}`;
|
|
182
|
-
const entry = byTest.get(key) ?? { file: test.file ?? null, outcomes: [] };
|
|
200
|
+
const entry = byTest.get(key) ?? { file: test.file ?? null, line: test.line ?? null, outcomes: [] };
|
|
201
|
+
// The newest run wins on location: a test that moved is reported where
|
|
202
|
+
// it now lives, not where it first failed.
|
|
203
|
+
if (test.line != null)
|
|
204
|
+
entry.line = test.line;
|
|
183
205
|
entry.outcomes.push(test.outcome);
|
|
184
206
|
byTest.set(key, entry);
|
|
185
207
|
}
|
|
@@ -191,6 +213,7 @@ function scoreHistory(history) {
|
|
|
191
213
|
...report,
|
|
192
214
|
title: key.split("\u0000")[1] ?? "",
|
|
193
215
|
file: entry.file,
|
|
216
|
+
line: entry.line,
|
|
194
217
|
badRuns: entry.outcomes.filter((o) => o === "flaky" || o === "unexpected").length,
|
|
195
218
|
});
|
|
196
219
|
}
|
|
@@ -203,7 +226,7 @@ function scoreHistory(history) {
|
|
|
203
226
|
* so this stays empty unless the history actually found something. "suspect"
|
|
204
227
|
* is deliberately not reported: it means the tool is not sure yet.
|
|
205
228
|
*/
|
|
206
|
-
function renderSummary(verdicts, totalRuns) {
|
|
229
|
+
function renderSummary(verdicts, totalRuns, connected = false) {
|
|
207
230
|
const flaky = verdicts.filter((v) => v.verdict === "flaky");
|
|
208
231
|
const broken = verdicts.filter((v) => v.verdict === "broken");
|
|
209
232
|
if (flaky.length === 0 && broken.length === 0)
|
|
@@ -221,10 +244,13 @@ function renderSummary(verdicts, totalRuns) {
|
|
|
221
244
|
lines.push(` ${v.title} — failed in ${v.badRuns} of ${v.runs} runs`);
|
|
222
245
|
}
|
|
223
246
|
}
|
|
247
|
+
if (!connected) {
|
|
248
|
+
lines.push(`Why they fail, and the fix as a pull request: ${exports.SITE_URL}`);
|
|
249
|
+
}
|
|
224
250
|
return lines.join("\n");
|
|
225
251
|
}
|
|
226
252
|
/** The same summary as GitHub-flavoured Markdown, for the run's own page. */
|
|
227
|
-
function renderMarkdown(verdicts, totalRuns) {
|
|
253
|
+
function renderMarkdown(verdicts, totalRuns, connected = false) {
|
|
228
254
|
const interesting = verdicts.filter((v) => v.verdict === "flaky" || v.verdict === "broken");
|
|
229
255
|
if (interesting.length === 0)
|
|
230
256
|
return "";
|
|
@@ -238,6 +264,14 @@ function renderMarkdown(verdicts, totalRuns) {
|
|
|
238
264
|
"| --- | --- | --- | --- |",
|
|
239
265
|
rows,
|
|
240
266
|
"",
|
|
267
|
+
// The run page is read by the whole team, not only whoever installed the
|
|
268
|
+
// package, so this is where the rest of them learn where the table came from.
|
|
269
|
+
...(connected
|
|
270
|
+
? []
|
|
271
|
+
: [
|
|
272
|
+
`<sub>Recorded by [FixSense](${exports.SITE_URL}?utm_source=step-summary) — it also says whether the test or the application is at fault, and opens the fix.</sub>`,
|
|
273
|
+
"",
|
|
274
|
+
]),
|
|
241
275
|
].join("\n");
|
|
242
276
|
}
|
|
243
277
|
/**
|
|
@@ -257,22 +291,127 @@ function writeStepSummary(markdown) {
|
|
|
257
291
|
// The step summary is a nicety; losing it is not worth a word.
|
|
258
292
|
}
|
|
259
293
|
}
|
|
294
|
+
/** A `::warning`/`::error` workflow command cannot carry a raw newline or `::`. */
|
|
295
|
+
function escapeAnnotation(message) {
|
|
296
|
+
return message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* GitHub workflow annotations for the tests that earned a verdict.
|
|
300
|
+
*
|
|
301
|
+
* This is the one thing a single-run flaky reporter does that we did not:
|
|
302
|
+
* `::warning file=…,line=…::` makes GitHub draw the finding inline on the exact
|
|
303
|
+
* line in the pull request's "Files changed" tab, not only in the job log.
|
|
304
|
+
* Ours carries what a single run cannot — how many of the last N runs the test
|
|
305
|
+
* misbehaved in — so the annotation is a measurement, not a one-off retry.
|
|
306
|
+
*
|
|
307
|
+
* A `flaky` verdict is a warning: the test is noise to quarantine. A `broken`
|
|
308
|
+
* verdict — fails every run — is an error: a real failure hiding behind a
|
|
309
|
+
* retry, and the louder level is the point. "suspect" is never annotated, for
|
|
310
|
+
* the same reason it is never summarised: the tool is not sure yet.
|
|
311
|
+
*/
|
|
312
|
+
function renderAnnotations(verdicts, totalRuns) {
|
|
313
|
+
const lines = [];
|
|
314
|
+
for (const v of verdicts) {
|
|
315
|
+
if (v.verdict !== "flaky" && v.verdict !== "broken")
|
|
316
|
+
continue;
|
|
317
|
+
const level = v.verdict === "broken" ? "error" : "warning";
|
|
318
|
+
const what = v.verdict === "broken"
|
|
319
|
+
? `fails every run — a real failure, not flakiness`
|
|
320
|
+
: `flaky in ${v.badRuns} of ${v.runs} runs (score ${v.score})`;
|
|
321
|
+
const props = [
|
|
322
|
+
v.file ? `file=${v.file}` : null,
|
|
323
|
+
v.line != null ? `line=${v.line}` : null,
|
|
324
|
+
`title=FixSense: ${escapeAnnotation(v.verdict)} test`,
|
|
325
|
+
]
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
.join(",");
|
|
328
|
+
lines.push(`::${level} ${props}::${escapeAnnotation(`${v.title} — ${what}, across the last ${totalRuns} runs`)}`);
|
|
329
|
+
}
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
/** True when we are inside a GitHub Actions job, where annotations render. */
|
|
333
|
+
function isGitHubActions(env) {
|
|
334
|
+
return env.GITHUB_ACTIONS === "true";
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Emit the annotations as workflow commands on stdout, where the runner reads
|
|
338
|
+
* them. Each must be alone on its line, so this writes them directly rather
|
|
339
|
+
* than folding them into the human summary.
|
|
340
|
+
*/
|
|
341
|
+
function writeAnnotations(lines) {
|
|
342
|
+
for (const line of lines)
|
|
343
|
+
console.log(line);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* One entry per test per run, whatever the runner reported.
|
|
347
|
+
*
|
|
348
|
+
* A run that listed the same test twice would count as two runs for it, and
|
|
349
|
+
* a retried test would read as several times as flaky as it is. The last
|
|
350
|
+
* entry wins: for a runner that reports each attempt, it is the one that
|
|
351
|
+
* knows how the test finally ended.
|
|
352
|
+
*/
|
|
353
|
+
function dedupe(tests) {
|
|
354
|
+
const byKey = new Map();
|
|
355
|
+
for (const t of tests) {
|
|
356
|
+
byKey.set(`${t.file ?? ""}\u0000${t.title}`, {
|
|
357
|
+
title: t.title,
|
|
358
|
+
file: t.file,
|
|
359
|
+
line: t.line ?? null,
|
|
360
|
+
outcome: t.outcome,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return [...byKey.values()];
|
|
364
|
+
}
|
|
365
|
+
/** Every supported CI sets CI=true, except Azure Pipelines, which sets TF_BUILD. */
|
|
366
|
+
function isCi(env) {
|
|
367
|
+
return Boolean(env.CI && env.CI !== "false") || env.TF_BUILD === "True";
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Said when a CI job finds no earlier runs to compare with.
|
|
371
|
+
*
|
|
372
|
+
* A CI job starts from a clean checkout, so without a cache the history holds
|
|
373
|
+
* one run every time, never reaches a verdict, and the package stays silent
|
|
374
|
+
* forever — which reads as "it does nothing". One line saying why is the
|
|
375
|
+
* difference between a team fixing that and a team uninstalling.
|
|
376
|
+
*/
|
|
377
|
+
function renderNoHistoryHint(docsUrl) {
|
|
378
|
+
return [
|
|
379
|
+
"FixSense: recorded this run, but found no earlier runs to compare it with.",
|
|
380
|
+
" CI starts every job clean, so the history is lost between runs.",
|
|
381
|
+
` Cache .fixsense/ between runs, or set FIXSENSE_API_KEY: ${docsUrl}`,
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
260
384
|
/**
|
|
261
385
|
* Record this run and report what the history now says.
|
|
262
386
|
*
|
|
263
387
|
* Returns the text that was printed, which is what the tests assert on.
|
|
264
388
|
*/
|
|
265
|
-
function recordAndReport(cwd, tests, now = new Date()) {
|
|
389
|
+
function recordAndReport(cwd, tests, now = new Date(), context = {}) {
|
|
266
390
|
const history = appendRun(readHistory(cwd), {
|
|
267
391
|
at: now.toISOString(),
|
|
268
|
-
tests: tests
|
|
392
|
+
tests: dedupe(tests),
|
|
269
393
|
});
|
|
270
394
|
writeHistory(cwd, history);
|
|
395
|
+
const connected = context.connected === true;
|
|
396
|
+
if (history.runs.length === 1 &&
|
|
397
|
+
!connected &&
|
|
398
|
+
context.docsUrl &&
|
|
399
|
+
isCi(context.env ?? process.env)) {
|
|
400
|
+
const hint = renderNoHistoryHint(context.docsUrl);
|
|
401
|
+
console.log(`\n${hint}\n`);
|
|
402
|
+
return hint;
|
|
403
|
+
}
|
|
271
404
|
const verdicts = scoreHistory(history);
|
|
272
|
-
const summary = renderSummary(verdicts, history.runs.length);
|
|
405
|
+
const summary = renderSummary(verdicts, history.runs.length, connected);
|
|
273
406
|
if (summary) {
|
|
274
407
|
console.log(`\n${summary}\n`);
|
|
275
|
-
writeStepSummary(renderMarkdown(verdicts, history.runs.length));
|
|
408
|
+
writeStepSummary(renderMarkdown(verdicts, history.runs.length, connected));
|
|
409
|
+
// On GitHub, also pin each verdict to its line in the PR. The summary and
|
|
410
|
+
// the annotations carry the same findings to two different places a
|
|
411
|
+
// reviewer looks: the run page, and the diff.
|
|
412
|
+
if (isGitHubActions(context.env ?? process.env)) {
|
|
413
|
+
writeAnnotations(renderAnnotations(verdicts, history.runs.length));
|
|
414
|
+
}
|
|
276
415
|
}
|
|
277
416
|
return summary;
|
|
278
417
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fixsense/cypress-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Cypress plugin that records test outcomes across runs so flaky tests can be identified over time.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
"keywords": [
|
|
14
14
|
"cypress",
|
|
15
15
|
"cypress-plugin",
|
|
16
|
+
"flaky",
|
|
17
|
+
"flaky-test",
|
|
16
18
|
"flaky-tests",
|
|
19
|
+
"flaky-test-detection",
|
|
20
|
+
"test-history",
|
|
21
|
+
"retry",
|
|
22
|
+
"quarantine",
|
|
23
|
+
"e2e",
|
|
17
24
|
"ci",
|
|
18
25
|
"test-reporting"
|
|
19
26
|
],
|
|
@@ -22,7 +29,7 @@
|
|
|
22
29
|
"url": "https://github.com/CopilotMe/fix-sense.git",
|
|
23
30
|
"directory": "packages/cypress-plugin"
|
|
24
31
|
},
|
|
25
|
-
"homepage": "https://fix-sense.com",
|
|
32
|
+
"homepage": "https://fix-sense.com/docs/integrations/cypress-plugin",
|
|
26
33
|
"scripts": {
|
|
27
34
|
"build": "tsc -p tsconfig.json",
|
|
28
35
|
"prepublishOnly": "npm run build"
|