@fixsense/cypress-plugin 0.2.1 → 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 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
  *
@@ -85,6 +88,8 @@ export declare function writeHistory(cwd: string, history: LocalHistory): void;
85
88
  export interface TestVerdict extends FlakinessReport {
86
89
  title: string;
87
90
  file: string | null;
91
+ /** The line this test is declared on, when the runner reports it. */
92
+ line: number | null;
88
93
  /** How many of the stored runs this test failed or needed a retry in. */
89
94
  badRuns: number;
90
95
  }
@@ -97,9 +102,9 @@ export declare function scoreHistory(history: LocalHistory): TestVerdict[];
97
102
  * so this stays empty unless the history actually found something. "suspect"
98
103
  * is deliberately not reported: it means the tool is not sure yet.
99
104
  */
100
- export declare function renderSummary(verdicts: TestVerdict[], totalRuns: number): string;
105
+ export declare function renderSummary(verdicts: TestVerdict[], totalRuns: number, connected?: boolean): string;
101
106
  /** The same summary as GitHub-flavoured Markdown, for the run's own page. */
102
- export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: number): string;
107
+ export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: number, connected?: boolean): string;
103
108
  /**
104
109
  * Write the Markdown where GitHub Actions renders it on the run's page.
105
110
  *
@@ -107,6 +112,29 @@ export declare function renderMarkdown(verdicts: TestVerdict[], totalRuns: numbe
107
112
  * page it is the first thing a reviewer sees.
108
113
  */
109
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;
110
138
  /**
111
139
  * One entry per test per run, whatever the runner reported.
112
140
  *
@@ -116,9 +144,27 @@ export declare function writeStepSummary(markdown: string): void;
116
144
  * knows how the test finally ended.
117
145
  */
118
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
+ }
119
165
  /**
120
166
  * Record this run and report what the history now says.
121
167
  *
122
168
  * Returns the text that was printed, which is what the tests assert on.
123
169
  */
124
- 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.MIN_FLAKY_RATE = exports.MIN_FLAKY_RUNS = 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,12 +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;
33
36
  exports.dedupe = dedupe;
37
+ exports.isCi = isCi;
38
+ exports.renderNoHistoryHint = renderNoHistoryHint;
34
39
  exports.recordAndReport = recordAndReport;
35
40
  const fs_1 = require("fs");
36
41
  const path_1 = require("path");
37
42
  exports.HISTORY_DIR = ".fixsense";
38
43
  exports.HISTORY_FILE = "history.json";
44
+ exports.SITE_URL = "https://fix-sense.com";
39
45
  /**
40
46
  * How many runs are kept.
41
47
  *
@@ -191,7 +197,11 @@ function scoreHistory(history) {
191
197
  for (const run of history.runs) {
192
198
  for (const test of run.tests ?? []) {
193
199
  const key = `${test.file ?? ""}\u0000${test.title}`;
194
- 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;
195
205
  entry.outcomes.push(test.outcome);
196
206
  byTest.set(key, entry);
197
207
  }
@@ -203,6 +213,7 @@ function scoreHistory(history) {
203
213
  ...report,
204
214
  title: key.split("\u0000")[1] ?? "",
205
215
  file: entry.file,
216
+ line: entry.line,
206
217
  badRuns: entry.outcomes.filter((o) => o === "flaky" || o === "unexpected").length,
207
218
  });
208
219
  }
@@ -215,7 +226,7 @@ function scoreHistory(history) {
215
226
  * so this stays empty unless the history actually found something. "suspect"
216
227
  * is deliberately not reported: it means the tool is not sure yet.
217
228
  */
218
- function renderSummary(verdicts, totalRuns) {
229
+ function renderSummary(verdicts, totalRuns, connected = false) {
219
230
  const flaky = verdicts.filter((v) => v.verdict === "flaky");
220
231
  const broken = verdicts.filter((v) => v.verdict === "broken");
221
232
  if (flaky.length === 0 && broken.length === 0)
@@ -233,10 +244,13 @@ function renderSummary(verdicts, totalRuns) {
233
244
  lines.push(` ${v.title} — failed in ${v.badRuns} of ${v.runs} runs`);
234
245
  }
235
246
  }
247
+ if (!connected) {
248
+ lines.push(`Why they fail, and the fix as a pull request: ${exports.SITE_URL}`);
249
+ }
236
250
  return lines.join("\n");
237
251
  }
238
252
  /** The same summary as GitHub-flavoured Markdown, for the run's own page. */
239
- function renderMarkdown(verdicts, totalRuns) {
253
+ function renderMarkdown(verdicts, totalRuns, connected = false) {
240
254
  const interesting = verdicts.filter((v) => v.verdict === "flaky" || v.verdict === "broken");
241
255
  if (interesting.length === 0)
242
256
  return "";
@@ -250,6 +264,14 @@ function renderMarkdown(verdicts, totalRuns) {
250
264
  "| --- | --- | --- | --- |",
251
265
  rows,
252
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
+ ]),
253
275
  ].join("\n");
254
276
  }
255
277
  /**
@@ -269,6 +291,57 @@ function writeStepSummary(markdown) {
269
291
  // The step summary is a nicety; losing it is not worth a word.
270
292
  }
271
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
+ }
272
345
  /**
273
346
  * One entry per test per run, whatever the runner reported.
274
347
  *
@@ -283,27 +356,62 @@ function dedupe(tests) {
283
356
  byKey.set(`${t.file ?? ""}\u0000${t.title}`, {
284
357
  title: t.title,
285
358
  file: t.file,
359
+ line: t.line ?? null,
286
360
  outcome: t.outcome,
287
361
  });
288
362
  }
289
363
  return [...byKey.values()];
290
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
+ }
291
384
  /**
292
385
  * Record this run and report what the history now says.
293
386
  *
294
387
  * Returns the text that was printed, which is what the tests assert on.
295
388
  */
296
- function recordAndReport(cwd, tests, now = new Date()) {
389
+ function recordAndReport(cwd, tests, now = new Date(), context = {}) {
297
390
  const history = appendRun(readHistory(cwd), {
298
391
  at: now.toISOString(),
299
392
  tests: dedupe(tests),
300
393
  });
301
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
+ }
302
404
  const verdicts = scoreHistory(history);
303
- const summary = renderSummary(verdicts, history.runs.length);
405
+ const summary = renderSummary(verdicts, history.runs.length, connected);
304
406
  if (summary) {
305
407
  console.log(`\n${summary}\n`);
306
- 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
+ }
307
415
  }
308
416
  return summary;
309
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fixsense/cypress-plugin",
3
- "version": "0.2.1",
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"