@flakiness/vitest 1.8.0 → 1.9.1

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
@@ -13,6 +13,7 @@ A custom Vitest reporter that generates Flakiness Reports from your Vitest test
13
13
  - [Viewing Reports](#viewing-reports)
14
14
  - [Features](#features)
15
15
  - [Test Location Tracking](#test-location-tracking)
16
+ - [Attachments](#attachments)
16
17
  - [Handling Test Duplicates](#handling-test-duplicates)
17
18
  - [Environment Detection](#environment-detection)
18
19
  - [CI Integration](#ci-integration)
@@ -29,6 +30,7 @@ A custom Vitest reporter that generates Flakiness Reports from your Vitest test
29
30
 
30
31
  ## Requirements
31
32
 
33
+ - Node.js 20.17.0 or higher (22.9.0 or higher on Node 22)
32
34
  - Vitest 4.0 or higher
33
35
  - Node.js project with a git repository (for commit information)
34
36
  - Valid Flakiness.io access token (for uploads)
@@ -77,14 +79,27 @@ npx flakiness show ./flakiness-report
77
79
 
78
80
  ## Uploading Reports
79
81
 
80
- Reports are automatically uploaded to Flakiness.io after test completion. Authentication can be done in two ways:
82
+ Reports are automatically uploaded to Flakiness.io after test completion. Authentication is resolved in the following order, and the first available method wins:
81
83
 
82
84
  - **Access token**: Provide a token via the `token` option or the `FLAKINESS_ACCESS_TOKEN` environment variable.
83
- - **GitHub OIDC**: When running in GitHub Actions, the reporter can authenticate using GitHub's OIDC token - no access token needed. This requires two conditions:
85
+ - **GitHub OIDC**: When running in GitHub Actions with no access token, the reporter can authenticate using GitHub's OIDC token. This requires three conditions:
84
86
  1. The `flakinessProject` option must be set to your Flakiness.io project identifier (`org/project`).
85
87
  2. The Flakiness.io project must be bound to the GitHub repository that runs the GitHub Actions workflow.
88
+ 3. The workflow must grant the `id-token: write` permission.
89
+ - **GitLab OIDC**: When running in GitLab CI/CD with no access token, the reporter can authenticate using a GitLab ID token. GitLab mints ID tokens when the job starts, so the job must declare one named `FLAKINESS_ID_TOKEN` whose audience is your project identifier:
86
90
 
87
- If upload fails, the report is still available locally in the output folder.
91
+ ```yaml
92
+ test:
93
+ id_tokens:
94
+ FLAKINESS_ID_TOKEN:
95
+ aud: my-org/my-project # must match the `flakinessProject` option
96
+ script:
97
+ - npx vitest run
98
+ ```
99
+
100
+ The `flakinessProject` option must be set, and the Flakiness.io project must be bound to the GitLab project running the pipeline.
101
+
102
+ If no method is available the upload is skipped, and if an upload fails the report is still available locally in the output folder. Either way the test run is unaffected.
88
103
 
89
104
  ## Viewing Reports
90
105
 
@@ -102,6 +117,17 @@ See [features.md](./features.md) for this reporter's status against the [Flakine
102
117
 
103
118
  When `includeTaskLocation: true` is set in your Vitest config, the reporter records the exact file, line, and column for each test. This enables precise navigation from the Flakiness.io dashboard back to your source code.
104
119
 
120
+ ### Attachments
121
+
122
+ [Browser Mode](https://vitest.dev/guide/browser/) records screenshots as Vitest artifacts. The reporter writes them into the local Flakiness report and uploads them along with it. Identical content is stored only once, no matter how many tests attach it.
123
+
124
+ Two kinds of screenshot are captured:
125
+
126
+ - **Failure screenshots.** Vitest screenshots every failing test when [`browser.screenshotFailures`](https://vitest.dev/config/browser/screenshotfailures) is enabled — which is the default outside of the Vitest UI. It is reported as `failure-screenshot.png`, and needs no changes to your tests.
127
+ - **Visual regressions.** Each failed `toMatchScreenshot()` comparison contributes `screenshot-1-expected.png`, `screenshot-1-actual.png` and `screenshot-1-diff.png`, numbered per comparison within the test. Vitest does not take a failure screenshot for these, since the comparison images already show the page.
128
+
129
+ Attachments of custom artifacts recorded through Vitest's `recordArtifact()` are named after the artifact type (`my-plugin:artifact-1`). Attachments of test annotations (`context.annotate()`) are not collected.
130
+
105
131
  ### Handling Test Duplicates
106
132
 
107
133
  Vitest allows creating tests with identical names:
@@ -175,7 +201,7 @@ reporters: [
175
201
 
176
202
  ### `flakinessProject?: string`
177
203
 
178
- The Flakiness.io project identifier in `org/project` format. Used for GitHub OIDC authentication — when set, and the Flakiness.io project is bound to the GitHub repository running the workflow, the reporter authenticates uploads via GitHub Actions OIDC token with no access token required.
204
+ The Flakiness.io project identifier in `org/project` format. Required for CI OIDC authentication. When set, and when the Flakiness.io project is bound to the repository running the pipeline, the reporter authenticates uploads via a GitHub Actions or GitLab CI/CD OIDC token with no access token required. See [Uploading Reports](#uploading-reports) for the per-provider requirements.
179
205
 
180
206
  ```typescript
181
207
  reporters: [
@@ -199,7 +225,7 @@ reporters: [
199
225
 
200
226
  Access token for authenticating with Flakiness.io when uploading reports. Defaults to the `FLAKINESS_ACCESS_TOKEN` environment variable.
201
227
 
202
- If no token is provided, reporter will attempt to authenticate using Github OIDC.
228
+ If no token is provided, the reporter falls back to CI OIDC on GitHub Actions and GitLab CI/CD. If that is unavailable too, the report is still generated locally but won't be uploaded automatically.
203
229
 
204
230
  ```typescript
205
231
  reporters: [
@@ -253,6 +279,7 @@ The reporter respects the following environment variables:
253
279
  - **`FLAKINESS_ENDPOINT`**: Custom Flakiness.io endpoint URL (equivalent to `endpoint` option)
254
280
  - **`FLAKINESS_OUTPUT_DIR`**: Output directory for reports (equivalent to `outputFolder` option)
255
281
  - **`FLAKINESS_DISABLE_UPLOAD`**: When set, disables report uploads (equivalent to `disableUpload` option)
282
+ - **`FLAKINESS_ID_TOKEN`**: GitLab CI/CD ID token used for OIDC authentication. Set by GitLab for jobs that declare it under `id_tokens:` (see [Uploading Reports](#uploading-reports))
256
283
 
257
284
  ## Example Configuration
258
285
 
package/lib/reporter.js CHANGED
@@ -12,7 +12,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
12
12
  throw Error('Dynamic require of "' + x + '" is not supported');
13
13
  });
14
14
  var __commonJS = (cb, mod) => function __require2() {
15
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
15
+ try {
16
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
+ } catch (e) {
18
+ throw mod = 0, e;
19
+ }
16
20
  };
17
21
  var __export = (target, all) => {
18
22
  for (var name in all)
@@ -807,9 +811,9 @@ var require_index_min = __commonJS({
807
811
  }
808
812
  });
809
813
 
810
- // node_modules/.pnpm/which@6.0.1/node_modules/which/lib/index.js
814
+ // node_modules/.pnpm/which@7.0.0/node_modules/which/lib/index.js
811
815
  var require_lib = __commonJS({
812
- "node_modules/.pnpm/which@6.0.1/node_modules/which/lib/index.js"(exports, module) {
816
+ "node_modules/.pnpm/which@7.0.0/node_modules/which/lib/index.js"(exports, module) {
813
817
  var { isexe, sync: isexeSync } = require_index_min();
814
818
  var { join, delimiter, sep, posix } = __require("path");
815
819
  var isWindows = process.platform === "win32";
@@ -895,14 +899,14 @@ var require_lib = __commonJS({
895
899
  }
896
900
  });
897
901
 
898
- // node_modules/.pnpm/@flakiness+flakiness-report@0.34.0_zod@4.4.3/node_modules/@flakiness/flakiness-report/lib/flakinessReport.js
902
+ // node_modules/.pnpm/@flakiness+flakiness-report@0.36.0_zod@4.4.3/node_modules/@flakiness/flakiness-report/lib/flakinessReport.js
899
903
  var FlakinessReport;
900
- ((FlakinessReport2) => {
901
- FlakinessReport2.CATEGORY_PLAYWRIGHT = "playwright";
902
- FlakinessReport2.CATEGORY_PYTEST = "pytest";
903
- FlakinessReport2.CATEGORY_JUNIT = "junit";
904
- FlakinessReport2.STREAM_STDOUT = 1;
905
- FlakinessReport2.STREAM_STDERR = 2;
904
+ ((FlakinessReport22) => {
905
+ FlakinessReport22.CATEGORY_PLAYWRIGHT = "playwright";
906
+ FlakinessReport22.CATEGORY_PYTEST = "pytest";
907
+ FlakinessReport22.CATEGORY_JUNIT = "junit";
908
+ FlakinessReport22.STREAM_STDOUT = 1;
909
+ FlakinessReport22.STREAM_STDERR = 2;
906
910
  })(FlakinessReport || (FlakinessReport = {}));
907
911
 
908
912
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
@@ -15425,7 +15429,7 @@ var classic_default = external_exports;
15425
15429
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/index.js
15426
15430
  var v4_default = classic_default;
15427
15431
 
15428
- // node_modules/.pnpm/@flakiness+flakiness-report@0.34.0_zod@4.4.3/node_modules/@flakiness/flakiness-report/lib/schema.js
15432
+ // node_modules/.pnpm/@flakiness+flakiness-report@0.36.0_zod@4.4.3/node_modules/@flakiness/flakiness-report/lib/schema.js
15429
15433
  var Schema;
15430
15434
  ((Schema2) => {
15431
15435
  Schema2.CommitId = v4_default.string().min(40).max(40);
@@ -15498,7 +15502,7 @@ var Schema;
15498
15502
  environmentIdx: v4_default.number().min(0).optional(),
15499
15503
  expectedStatus: Schema2.TestStatus.optional(),
15500
15504
  status: Schema2.TestStatus.optional(),
15501
- startTimestamp: Schema2.UnixTimestampMS,
15505
+ startTimestamp: Schema2.UnixTimestampMS.optional(),
15502
15506
  duration: Schema2.DurationMS.optional(),
15503
15507
  timeout: Schema2.DurationMS.optional(),
15504
15508
  annotations: v4_default.array(Schema2.Annotation).optional(),
@@ -15586,7 +15590,7 @@ var Schema;
15586
15590
  });
15587
15591
  })(Schema || (Schema = {}));
15588
15592
 
15589
- // node_modules/.pnpm/@flakiness+sdk@3.3.1_@flakiness+flakiness-report@0.34.0_zod@4.4.3_/node_modules/@flakiness/sdk/lib/index.js
15593
+ // node_modules/.pnpm/@flakiness+sdk@3.6.0_@flakiness+flakiness-report@0.36.0_zod@4.4.3_/node_modules/@flakiness/sdk/lib/index.js
15590
15594
  var import_debug = __toESM(require_src(), 1);
15591
15595
  import os from "os";
15592
15596
  import assert2 from "assert";
@@ -15629,7 +15633,7 @@ function stableHash(t) {
15629
15633
  }
15630
15634
  var dist_default = stableHash;
15631
15635
 
15632
- // node_modules/.pnpm/@flakiness+sdk@3.3.1_@flakiness+flakiness-report@0.34.0_zod@4.4.3_/node_modules/@flakiness/sdk/lib/index.js
15636
+ // node_modules/.pnpm/@flakiness+sdk@3.6.0_@flakiness+flakiness-report@0.36.0_zod@4.4.3_/node_modules/@flakiness/sdk/lib/index.js
15633
15637
  import assert22 from "assert";
15634
15638
  import fs4 from "fs";
15635
15639
  import { URL as URL2 } from "url";
@@ -15637,8 +15641,10 @@ var import_debug2 = __toESM(require_src(), 1);
15637
15641
  var import_which = __toESM(require_lib(), 1);
15638
15642
  import fs7 from "node:fs";
15639
15643
  import path3 from "node:path";
15644
+ import path4 from "node:path";
15645
+ import { styleText as styleText2 } from "node:util";
15640
15646
  import fs8 from "fs";
15641
- import path4 from "path";
15647
+ import path5 from "path";
15642
15648
  var __defProp2 = Object.defineProperty;
15643
15649
  var __export2 = (target, all) => {
15644
15650
  for (var name in all)
@@ -15768,31 +15774,50 @@ async function compressTextAsync(text) {
15768
15774
  }
15769
15775
  });
15770
15776
  }
15771
- async function retryWithBackoff(job, backoff = []) {
15772
- for (const timeout of backoff) {
15777
+ async function retryWithBackoff(job, backoff) {
15778
+ for (let i = 0; i < 100; ++i) {
15773
15779
  try {
15774
15780
  return await job();
15775
- } catch (e) {
15781
+ } catch (error51) {
15782
+ const timeout = backoff(error51, i);
15783
+ if (timeout === void 0)
15784
+ throw error51;
15776
15785
  await new Promise((x) => setTimeout(x, timeout));
15777
15786
  }
15778
15787
  }
15779
15788
  return await job();
15780
15789
  }
15781
15790
  var HTTP_BACKOFF = [100, 500, 1e3, 1e3, 1e3, 1e3];
15791
+ var HTTPResponseError = class extends Error {
15792
+ constructor(message, status) {
15793
+ super(message);
15794
+ this.status = status;
15795
+ }
15796
+ status;
15797
+ };
15798
+ function isRetryableHTTPError(error51, extraRetryableStatuses) {
15799
+ if (error51 instanceof HTTPResponseError) {
15800
+ return error51.status === 408 || error51.status === 425 || error51.status === 429 || error51.status >= 500 && error51.status <= 599 || extraRetryableStatuses.includes(error51.status);
15801
+ }
15802
+ return !(error51 instanceof Error && error51.name === "AbortError");
15803
+ }
15782
15804
  async function fetchOk(input, init) {
15783
15805
  const response = await fetch(input, init);
15784
15806
  if (!response.ok) {
15785
- const url2 = response.url || (input instanceof URL ? input.href : typeof input === "string" ? input : input.url);
15786
15807
  const body = await response.text().catch(() => "");
15787
- throw new Error(response.status + " " + url2 + " " + body);
15808
+ throw new HTTPResponseError(response.status + " " + response.url + " " + body, response.status);
15788
15809
  }
15789
15810
  return response;
15790
15811
  }
15791
- async function getJSON(input, init, backoff = HTTP_BACKOFF) {
15812
+ async function getJSON(input, init, backoff = HTTP_BACKOFF, extraRetryableStatuses = []) {
15813
+ backoff = backoff.slice();
15792
15814
  return await retryWithBackoff(async () => {
15793
15815
  const response = await fetchOk(input, init);
15794
- return await response.json();
15795
- }, backoff);
15816
+ const text = await response.text();
15817
+ return text.trim() ? JSON.parse(text) : void 0;
15818
+ }, (error51, retryIdx) => {
15819
+ return retryIdx < backoff.length && isRetryableHTTPError(error51, extraRetryableStatuses) ? backoff[retryIdx] : void 0;
15820
+ });
15796
15821
  }
15797
15822
  async function putBuffer(input, body, headers, backoff = HTTP_BACKOFF) {
15798
15823
  await retryWithBackoff(async () => {
@@ -15802,7 +15827,9 @@ async function putBuffer(input, body, headers, backoff = HTTP_BACKOFF) {
15802
15827
  body: new Uint8Array(body)
15803
15828
  });
15804
15829
  await response.arrayBuffer();
15805
- }, backoff);
15830
+ }, (error51, retryIdx) => {
15831
+ return retryIdx < backoff.length && isRetryableHTTPError(error51, []) ? backoff[retryIdx] : void 0;
15832
+ });
15806
15833
  }
15807
15834
  function shell(command, args, options) {
15808
15835
  try {
@@ -15836,6 +15863,10 @@ function sha1File(filePath) {
15836
15863
  });
15837
15864
  });
15838
15865
  }
15866
+ function isCI() {
15867
+ const ci = (process.env.CI ?? "").toLowerCase();
15868
+ return ci !== "" && ci !== "false" && ci !== "0";
15869
+ }
15839
15870
  var log = (0, import_debug.default)("fk:git");
15840
15871
  var execAsync = promisify(exec);
15841
15872
  var GIT_SAFE_ENV = {
@@ -15869,6 +15900,7 @@ var GitWorktree = class _GitWorktree {
15869
15900
  this._gitRoot = _gitRoot;
15870
15901
  this._posixGitRoot = toPosixAbsolutePath(this._gitRoot);
15871
15902
  }
15903
+ _gitRoot;
15872
15904
  /**
15873
15905
  * Initializes a GitWorktree for any path inside a git repository and resolves the
15874
15906
  * HEAD commit id in a single call.
@@ -16089,6 +16121,8 @@ var GithubOIDC = class _GithubOIDC {
16089
16121
  this._requestUrl = _requestUrl;
16090
16122
  this._requestToken = _requestToken;
16091
16123
  }
16124
+ _requestUrl;
16125
+ _requestToken;
16092
16126
  /**
16093
16127
  * Creates a GithubOIDC instance from GitHub Actions environment variables.
16094
16128
  *
@@ -16103,6 +16137,13 @@ var GithubOIDC = class _GithubOIDC {
16103
16137
  const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
16104
16138
  return requestUrl && requestToken ? new _GithubOIDC(requestUrl, requestToken) : void 0;
16105
16139
  }
16140
+ /**
16141
+ * Human-readable name of the CI provider, suitable for log messages.
16142
+ *
16143
+ * To branch on the provider, use `oidc instanceof GithubOIDC` instead — this string is meant
16144
+ * for humans and may be reworded.
16145
+ */
16146
+ name = "GitHub Actions";
16106
16147
  /**
16107
16148
  * Mints a Flakiness access token for the specified project via GitHub OIDC.
16108
16149
  *
@@ -16131,11 +16172,98 @@ var GithubOIDC = class _GithubOIDC {
16131
16172
  } catch (error51) {
16132
16173
  throw new Error(`Failed to request GitHub OIDC token: ${error51.message || String(error51)}`);
16133
16174
  }
16134
- if (!json2.value)
16175
+ if (!json2?.value)
16135
16176
  throw new Error("GitHub OIDC token response did not contain a token value.");
16136
16177
  return json2.value;
16137
16178
  }
16138
16179
  };
16180
+ var GitlabOIDC = class _GitlabOIDC {
16181
+ constructor(_idToken) {
16182
+ this._idToken = _idToken;
16183
+ }
16184
+ _idToken;
16185
+ /**
16186
+ * Creates a GitlabOIDC instance from GitLab CI/CD environment variables.
16187
+ *
16188
+ * Reads the `FLAKINESS_ID_TOKEN` environment variable, which GitLab CI/CD sets for jobs
16189
+ * that declare an `id_tokens: FLAKINESS_ID_TOKEN:` entry in `.gitlab-ci.yml`.
16190
+ *
16191
+ * @returns {GitlabOIDC | undefined} A GitlabOIDC instance if the environment variable is
16192
+ * present, or `undefined` if not running in GitLab CI/CD with an ID token configured.
16193
+ */
16194
+ static initializeFromEnv() {
16195
+ const idToken = process.env.FLAKINESS_ID_TOKEN;
16196
+ return idToken ? new _GitlabOIDC(idToken) : void 0;
16197
+ }
16198
+ /**
16199
+ * Human-readable name of the CI provider, suitable for log messages.
16200
+ *
16201
+ * To branch on the provider, use `oidc instanceof GitlabOIDC` instead — this string is meant
16202
+ * for humans and may be reworded.
16203
+ */
16204
+ name = "GitLab CI/CD";
16205
+ /**
16206
+ * Returns the Flakiness access token for the specified project — the GitLab ID token itself.
16207
+ *
16208
+ * This method succeeds as long as the ID token names `flakinessProject` in its `aud` claim.
16209
+ * However, the returned token can only be used to upload reports if the Flakiness.io project
16210
+ * is bound to the GitLab project running the pipeline. If the project is not bound,
16211
+ * Flakiness.io will reject the token on upload.
16212
+ *
16213
+ * @param {string} flakinessProject - The flakiness project identifier in `"org/project"` format.
16214
+ *
16215
+ * @returns {Promise<string>} A Flakiness access token.
16216
+ *
16217
+ * @throws {Error} If the ID token is not a JWT, carries no `aud` claim, or its `aud` claim
16218
+ * does not include `flakinessProject`. GitLab mints the token when the job starts, so all
16219
+ * three can only be fixed in `.gitlab-ci.yml`.
16220
+ */
16221
+ async createFlakinessAccessToken(flakinessProject) {
16222
+ const payload = jwtPayload(this._idToken);
16223
+ if (!payload) {
16224
+ throw new Error([
16225
+ `GitLab ID token is not a JWT.`,
16226
+ `Check that FLAKINESS_ID_TOKEN comes from an id_tokens entry with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`
16227
+ ].join(" "));
16228
+ }
16229
+ const audience = audienceClaim(payload);
16230
+ if (!audience.length) {
16231
+ throw new Error([
16232
+ `GitLab ID token has no audience, so it cannot upload to "${flakinessProject}".`,
16233
+ `Declare the FLAKINESS_ID_TOKEN id_token with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`
16234
+ ].join(" "));
16235
+ }
16236
+ if (!audience.includes(flakinessProject)) {
16237
+ throw new Error([
16238
+ `GitLab ID token audience is ${audience.map((aud) => JSON.stringify(aud)).join(", ")}, but the report uploads to "${flakinessProject}".`,
16239
+ `Set the audience of the FLAKINESS_ID_TOKEN id_token in .gitlab-ci.yml to "${flakinessProject}".`
16240
+ ].join(" "));
16241
+ }
16242
+ return this._idToken;
16243
+ }
16244
+ };
16245
+ function jwtPayload(jwt2) {
16246
+ const payload = jwt2.split(".")[1];
16247
+ if (!payload)
16248
+ return void 0;
16249
+ try {
16250
+ const json2 = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
16251
+ return json2 && typeof json2 === "object" && !Array.isArray(json2) ? json2 : void 0;
16252
+ } catch {
16253
+ return void 0;
16254
+ }
16255
+ }
16256
+ function audienceClaim(payload) {
16257
+ const aud = payload["aud"];
16258
+ if (typeof aud === "string")
16259
+ return [aud];
16260
+ if (Array.isArray(aud))
16261
+ return aud.filter((entry) => typeof entry === "string");
16262
+ return [];
16263
+ }
16264
+ function initializeOIDCFromEnv() {
16265
+ return GithubOIDC.initializeFromEnv() ?? GitlabOIDC.initializeFromEnv();
16266
+ }
16139
16267
  var reportUtils_exports = {};
16140
16268
  __export2(reportUtils_exports, {
16141
16269
  collectSources: () => collectSources,
@@ -16488,21 +16616,21 @@ async function createDataAttachment(contentType, data) {
16488
16616
  async function uploadReport(report, attachments, options) {
16489
16617
  let flakinessAccessToken = options?.flakinessAccessToken ?? process.env["FLAKINESS_ACCESS_TOKEN"];
16490
16618
  const logger = options?.logger ?? console;
16491
- const githubOIDC = GithubOIDC.initializeFromEnv();
16492
- if (!flakinessAccessToken && githubOIDC) {
16619
+ const oidc = initializeOIDCFromEnv();
16620
+ if (!flakinessAccessToken && oidc) {
16493
16621
  if (!report.flakinessProject) {
16494
- const reason = "`flakinessProject` is not configured to upload using Github OIDC.";
16495
- if (process.env.CI)
16622
+ const reason = `\`flakinessProject\` is not configured to upload using ${oidc.name} OIDC.`;
16623
+ if (isCI())
16496
16624
  logger.warn(`[flakiness.io] \u26A0 Skipping upload: ${reason}`);
16497
16625
  return { status: "skipped", reason };
16498
16626
  }
16499
16627
  try {
16500
- flakinessAccessToken = await githubOIDC.createFlakinessAccessToken(report.flakinessProject);
16628
+ flakinessAccessToken = await oidc.createFlakinessAccessToken(report.flakinessProject);
16501
16629
  if (!flakinessAccessToken)
16502
16630
  throw new Error("token is empty");
16503
16631
  } catch (e) {
16504
16632
  const errorMessage = e.message || String(e);
16505
- logger.error(`[flakiness.io] \u2715 Unexpected error while fetching Github OIDC token: ${errorMessage}`);
16633
+ logger.error(`[flakiness.io] \u2715 Unexpected error while fetching ${oidc.name} OIDC token: ${errorMessage}`);
16506
16634
  if (options?.throwOnFailure)
16507
16635
  throw e;
16508
16636
  return { status: "failed", error: errorMessage };
@@ -16510,7 +16638,7 @@ async function uploadReport(report, attachments, options) {
16510
16638
  }
16511
16639
  if (!flakinessAccessToken) {
16512
16640
  const reason = "No FLAKINESS_ACCESS_TOKEN found";
16513
- if (process.env.CI)
16641
+ if (isCI())
16514
16642
  logger.warn(`[flakiness.io] \u26A0 Skipping upload: ${reason}`);
16515
16643
  return { status: "skipped", reason };
16516
16644
  }
@@ -16582,12 +16710,16 @@ var ReportUpload = class {
16582
16710
  if (response?.error || !response.result)
16583
16711
  return { success: false, message: response.error };
16584
16712
  const webUrl = new URL2(response.result.webUrl, this._options.flakinessEndpoint).toString();
16585
- const attachmentsPresignedUrls = await this._api("/api/upload/attachments", response.result.uploadToken, {
16586
- attachmentIds: this._attachments.map((a) => a.id)
16587
- });
16588
- if (attachmentsPresignedUrls?.error || !attachmentsPresignedUrls.result)
16589
- return { success: false, message: attachmentsPresignedUrls.error };
16590
- const attachments = new Map(attachmentsPresignedUrls.result.map((a) => [a.attachmentId, a.presignedUrl]));
16713
+ const attachments = /* @__PURE__ */ new Map();
16714
+ if (this._attachments.length) {
16715
+ const attachmentsPresignedUrls = await this._api("/api/upload/attachments", response.result.uploadToken, {
16716
+ attachmentIds: this._attachments.map((a) => a.id)
16717
+ });
16718
+ if (attachmentsPresignedUrls?.error || !attachmentsPresignedUrls.result)
16719
+ return { success: false, message: attachmentsPresignedUrls.error };
16720
+ for (const attachment of attachmentsPresignedUrls.result)
16721
+ attachments.set(attachment.attachmentId, attachment.presignedUrl);
16722
+ }
16591
16723
  await Promise.all([
16592
16724
  this._uploadReport(JSON.stringify(this._report), response.result.presignedReportUrl),
16593
16725
  ...this._attachments.map((attachment) => {
@@ -16597,7 +16729,9 @@ var ReportUpload = class {
16597
16729
  return this._uploadAttachment(attachment, uploadURL);
16598
16730
  })
16599
16731
  ]);
16600
- await this._api("/api/upload/finish", response.result.uploadToken);
16732
+ const finishResponse = await this._api("/api/upload/finish", response.result.uploadToken);
16733
+ if (finishResponse.error)
16734
+ return { success: false, message: finishResponse.error };
16601
16735
  return { success: true, reportUrl: webUrl };
16602
16736
  }
16603
16737
  async _uploadReport(data, uploadUrl) {
@@ -16745,9 +16879,17 @@ function quoteShellArgument(argument) {
16745
16879
  return argument;
16746
16880
  return JSON.stringify(argument);
16747
16881
  }
16882
+ function showReportMessage(reportFolder) {
16883
+ if (isCI())
16884
+ return `Flakiness report written to ${path4.resolve(reportFolder)}`;
16885
+ const command = showReportCommand(reportFolder);
16886
+ return `To open last Flakiness report, run:
16887
+
16888
+ ${styleText2("cyan", command)}`;
16889
+ }
16748
16890
  async function writeReport(report, attachments, outputFolder) {
16749
- const reportPath = path4.join(outputFolder, "report.json");
16750
- const attachmentsFolder = path4.join(outputFolder, "attachments");
16891
+ const reportPath = path5.join(outputFolder, "report.json");
16892
+ const attachmentsFolder = path5.join(outputFolder, "attachments");
16751
16893
  await fs8.promises.rm(outputFolder, { recursive: true, force: true });
16752
16894
  await fs8.promises.mkdir(outputFolder, { recursive: true });
16753
16895
  await fs8.promises.writeFile(reportPath, JSON.stringify(report), "utf-8");
@@ -16755,7 +16897,7 @@ async function writeReport(report, attachments, outputFolder) {
16755
16897
  await fs8.promises.mkdir(attachmentsFolder);
16756
16898
  const movedAttachments = [];
16757
16899
  for (const attachment of attachments) {
16758
- const attachmentPath = path4.join(attachmentsFolder, attachment.id);
16900
+ const attachmentPath = path5.join(attachmentsFolder, attachment.id);
16759
16901
  if (attachment.type === "file")
16760
16902
  await fs8.promises.cp(attachment.path, attachmentPath);
16761
16903
  else if (attachment.type === "buffer")
@@ -16779,7 +16921,7 @@ import * as nodeUtil from "node:util";
16779
16921
  // package.json
16780
16922
  var package_default = {
16781
16923
  name: "@flakiness/vitest",
16782
- version: "1.8.0",
16924
+ version: "1.9.1",
16783
16925
  packageManager: "pnpm@11.5.0",
16784
16926
  description: "",
16785
16927
  main: "lib/reporter.js",
@@ -16799,22 +16941,32 @@ var package_default = {
16799
16941
  engines: {
16800
16942
  node: "^20.17.0 || >=22.9.0"
16801
16943
  },
16944
+ devEngines: {
16945
+ runtime: {
16946
+ name: "node",
16947
+ version: "^20.17.0",
16948
+ onFail: "download"
16949
+ }
16950
+ },
16802
16951
  devDependencies: {
16803
- "@flakiness/flakiness-report": "^0.34.0",
16804
- "@flakiness/sdk": "^3.2.0",
16805
- "@flakiness/vitest": "1.6.0",
16952
+ "@flakiness/flakiness-report": "^0.36.0",
16953
+ "@flakiness/sdk": "^3.6.0",
16954
+ "@flakiness/vitest": "1.8.0",
16806
16955
  "@types/node": "^25.6.2",
16807
- "@vitest/utils": "^4.1.5",
16808
- esbuild: "^0.27.7",
16956
+ "@vitest/browser": "^4.1.10",
16957
+ "@vitest/browser-playwright": "^4.1.10",
16958
+ "@vitest/utils": "^4.1.10",
16959
+ esbuild: "^0.28.1",
16809
16960
  kubik: "^0.24.0",
16961
+ playwright: "^1.61.1",
16810
16962
  tsx: "^4.21.0",
16811
16963
  typescript: "^5.9.3",
16812
- vitest: "^4.1.5"
16964
+ vitest: "^4.1.10"
16813
16965
  }
16814
16966
  };
16815
16967
 
16816
16968
  // src/reporter.ts
16817
- var styleText2 = (format, text) => nodeUtil.styleText?.(format, text) ?? text;
16969
+ var styleText3 = (format, text) => nodeUtil.styleText?.(format, text) ?? text;
16818
16970
  var FKVitestReporter = class {
16819
16971
  constructor(_options) {
16820
16972
  this._options = _options;
@@ -16823,8 +16975,8 @@ var FKVitestReporter = class {
16823
16975
  _impl;
16824
16976
  _vitest;
16825
16977
  _logger = {
16826
- warn: (txt) => console.warn(styleText2("yellow", txt)),
16827
- error: (txt) => console.error(styleText2("red", txt)),
16978
+ warn: (txt) => console.warn(styleText3("yellow", txt)),
16979
+ error: (txt) => console.error(styleText3("red", txt)),
16828
16980
  log: (txt) => console.log(txt)
16829
16981
  };
16830
16982
  setLoggerForTest(logger) {
@@ -16870,6 +17022,7 @@ var ReporterImpl = class _ReporterImpl {
16870
17022
  _cpuUtilization = new CPUUtilization({ precision: 10 });
16871
17023
  _ramUtilization = new RAMUtilization({ precision: 10 });
16872
17024
  _startTimestamp = Date.now();
17025
+ _attachments = /* @__PURE__ */ new Map();
16873
17026
  _stdio = /* @__PURE__ */ new Map();
16874
17027
  _environments = [];
16875
17028
  static create(rootPath, projects, options, logger, vitestVersion, config2) {
@@ -16907,7 +17060,7 @@ var ReporterImpl = class _ReporterImpl {
16907
17060
  column: frame.column
16908
17061
  } : void 0;
16909
17062
  }
16910
- _collectSuite(fkParent, suite) {
17063
+ async _collectSuite(fkParent, suite) {
16911
17064
  const fkSuite = {
16912
17065
  type: "suite",
16913
17066
  title: suite.name,
@@ -16920,9 +17073,42 @@ var ReporterImpl = class _ReporterImpl {
16920
17073
  fkParent.suites ??= [];
16921
17074
  fkParent.suites.push(fkSuite);
16922
17075
  for (const s of suite.children.suites())
16923
- this._collectSuite(fkSuite, s);
17076
+ await this._collectSuite(fkSuite, s);
16924
17077
  for (const t of suite.children.tests())
16925
- this._collectTest(fkSuite, t);
17078
+ await this._collectTest(fkSuite, t);
17079
+ }
17080
+ async _collectAttachments(testCase) {
17081
+ const attachments = [];
17082
+ const add = async (attachment, name) => {
17083
+ const fkAttachment = await createFKAttachment(attachment);
17084
+ this._attachments.set(fkAttachment.id, fkAttachment);
17085
+ attachments.push({
17086
+ contentType: fkAttachment.contentType,
17087
+ id: fkAttachment.id,
17088
+ name: name + extension(attachment)
17089
+ });
17090
+ };
17091
+ let visualRegressionIndex = 0;
17092
+ let failureScreenshotIndex = 0;
17093
+ for (const artifact of testCase.artifacts?.() ?? []) {
17094
+ if (artifact.type === "internal:annotation")
17095
+ continue;
17096
+ if (artifact.type === "internal:toMatchScreenshot") {
17097
+ const prefix = `screenshot-${++visualRegressionIndex}`;
17098
+ for (const attachment of artifact.attachments)
17099
+ await add(attachment, `${prefix}-${VISUAL_REGRESSION_NAMES[attachment.name]}`);
17100
+ } else if (artifact.type === "internal:failureScreenshot") {
17101
+ for (const attachment of artifact.attachments) {
17102
+ ++failureScreenshotIndex;
17103
+ await add(attachment, failureScreenshotIndex === 1 ? "failure-screenshot" : `failure-screenshot-${failureScreenshotIndex}`);
17104
+ }
17105
+ } else {
17106
+ const custom2 = artifact;
17107
+ for (const [index, attachment] of (custom2.attachments ?? []).entries())
17108
+ await add(attachment, `${custom2.type}-${index + 1}`);
17109
+ }
17110
+ }
17111
+ return attachments;
16926
17112
  }
16927
17113
  async _collectTest(fkParent, testCase) {
16928
17114
  const environmentIdx = this._environments.findIndex((env) => env.name === this._envName(testCase.project));
@@ -16961,9 +17147,12 @@ var ReporterImpl = class _ReporterImpl {
16961
17147
  stdio.push({
16962
17148
  text: entry.content,
16963
17149
  stream: entry.type === "stdout" ? FlakinessReport.STREAM_STDOUT : FlakinessReport.STREAM_STDERR,
16964
- dts: entry.time - ts
17150
+ // Older Vitest versions can report a stale timestamp when output
17151
+ // switches between stdout and stderr. Preserve the emitted order, but
17152
+ // never serialize an invalid negative DurationMS.
17153
+ dts: Math.max(0, entry.time - ts)
16965
17154
  });
16966
- ts = entry.time;
17155
+ ts = Math.max(ts, entry.time);
16967
17156
  }
16968
17157
  this._stdio.delete(testCase.id);
16969
17158
  const testFile = this._worktree.gitPath(testCase.module.moduleId);
@@ -16983,6 +17172,7 @@ var ReporterImpl = class _ReporterImpl {
16983
17172
  }));
16984
17173
  if (testCase.options.fails)
16985
17174
  annotations.push({ type: "fail" });
17175
+ const attachments = await this._collectAttachments(testCase);
16986
17176
  const expectedStatus = testCase.options.fails ? "failed" : "passed";
16987
17177
  const oppositeStatus = expectedStatus === "failed" ? "passed" : "failed";
16988
17178
  for (let i = 0; i < retryCount; ++i) {
@@ -17001,7 +17191,8 @@ var ReporterImpl = class _ReporterImpl {
17001
17191
  // decides to push a new annotation, then they can do it per-attempt safely.
17002
17192
  stdio: stdio.slice(),
17003
17193
  errors: errors.slice(),
17004
- annotations: annotations.slice()
17194
+ annotations: annotations.slice(),
17195
+ attachments: attachments.slice()
17005
17196
  });
17006
17197
  }
17007
17198
  fkTest.attempts.push({
@@ -17015,7 +17206,8 @@ var ReporterImpl = class _ReporterImpl {
17015
17206
  // See https://github.com/vitest-dev/vitest/issues/10303
17016
17207
  stdio,
17017
17208
  errors,
17018
- annotations
17209
+ annotations,
17210
+ attachments
17019
17211
  });
17020
17212
  }
17021
17213
  _warnDuplicates(duplicates) {
@@ -17109,7 +17301,7 @@ var ReporterImpl = class _ReporterImpl {
17109
17301
  return { duplicates, testIdToTests };
17110
17302
  }
17111
17303
  async onTestRunEnd(testModules, unhandledErrors, reason) {
17112
- const fileSuites = testModules.map((file2) => {
17304
+ const fileSuites = await Promise.all(testModules.map(async (file2) => {
17113
17305
  const fkFileSuite = {
17114
17306
  type: "file",
17115
17307
  title: file2.relativeModuleId,
@@ -17120,11 +17312,11 @@ var ReporterImpl = class _ReporterImpl {
17120
17312
  }
17121
17313
  };
17122
17314
  for (const suite of file2.children.suites())
17123
- this._collectSuite(fkFileSuite, suite);
17315
+ await this._collectSuite(fkFileSuite, suite);
17124
17316
  for (const test of file2.children.tests())
17125
- this._collectTest(fkFileSuite, test);
17317
+ await this._collectTest(fkFileSuite, test);
17126
17318
  return fkFileSuite;
17127
- });
17319
+ }));
17128
17320
  clearTimeout(this._telemetryTimer);
17129
17321
  this._cpuUtilization.sample();
17130
17322
  this._ramUtilization.sample();
@@ -17168,26 +17360,41 @@ var ReporterImpl = class _ReporterImpl {
17168
17360
  process.cwd(),
17169
17361
  process.env.FLAKINESS_OUTPUT_DIR ?? "flakiness-report"
17170
17362
  );
17171
- await writeReport(report, [], outputFolder);
17363
+ const attachments = await writeReport(report, Array.from(this._attachments.values()), outputFolder);
17172
17364
  const disableUpload = !!this._options.disableUpload || !!process.env.FLAKINESS_DISABLE_UPLOAD;
17173
17365
  if (!disableUpload) {
17174
- await uploadReport(report, [], {
17366
+ await uploadReport(report, attachments, {
17175
17367
  flakinessAccessToken: this._options.token,
17176
17368
  flakinessEndpoint: this._options.endpoint,
17177
17369
  logger: this._logger
17178
17370
  });
17179
17371
  }
17180
- const command = showReportCommand(outputFolder);
17181
17372
  this._logger.log(`
17182
- To open last Flakiness report, run:
17183
-
17184
- ${styleText2("cyan", command)}
17185
- `);
17373
+ ${showReportMessage(outputFolder)}
17374
+ `);
17186
17375
  }
17187
17376
  };
17188
17377
  function dupeSuffix(dupeIndex) {
17189
17378
  return ` \u2013 dupe #${dupeIndex}`;
17190
17379
  }
17380
+ async function createFKAttachment(attachment) {
17381
+ const contentType = attachment.contentType ?? "application/octet-stream";
17382
+ if (attachment.path)
17383
+ return await reportUtils_exports.createFileAttachment(contentType, attachment.path);
17384
+ if (typeof attachment.body === "string")
17385
+ return reportUtils_exports.createDataAttachment(contentType, Buffer.from(attachment.body, attachment.bodyEncoding === "utf-8" ? "utf8" : "base64"));
17386
+ if (attachment.body)
17387
+ return reportUtils_exports.createDataAttachment(contentType, Buffer.from(attachment.body));
17388
+ throw new Error(`Failed to parse attachment: it has neither path to body. This is likely a bug; please report to https://github.com/flakiness/vitest/issues/new`);
17389
+ }
17390
+ var VISUAL_REGRESSION_NAMES = {
17391
+ reference: "expected",
17392
+ actual: "actual",
17393
+ diff: "diff"
17394
+ };
17395
+ function extension(attachment) {
17396
+ return attachment.path ? path.extname(attachment.path) : "";
17397
+ }
17191
17398
  export {
17192
17399
  FKVitestReporter as default
17193
17400
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flakiness/vitest",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "",
5
5
  "main": "lib/reporter.js",
6
6
  "keywords": [],
@@ -14,17 +14,28 @@
14
14
  "engines": {
15
15
  "node": "^20.17.0 || >=22.9.0"
16
16
  },
17
+ "devEngines": {
18
+ "runtime": {
19
+ "name": "node",
20
+ "version": "^20.17.0",
21
+ "onFail": "download"
22
+ }
23
+ },
17
24
  "devDependencies": {
18
- "@flakiness/flakiness-report": "^0.34.0",
19
- "@flakiness/sdk": "^3.2.0",
20
- "@flakiness/vitest": "1.6.0",
25
+ "@flakiness/flakiness-report": "^0.36.0",
26
+ "@flakiness/sdk": "^3.6.0",
27
+ "@flakiness/vitest": "1.8.0",
21
28
  "@types/node": "^25.6.2",
22
- "@vitest/utils": "^4.1.5",
23
- "esbuild": "^0.27.7",
29
+ "@vitest/browser": "^4.1.10",
30
+ "@vitest/browser-playwright": "^4.1.10",
31
+ "@vitest/utils": "^4.1.10",
32
+ "esbuild": "^0.28.1",
24
33
  "kubik": "^0.24.0",
34
+ "playwright": "^1.61.1",
25
35
  "tsx": "^4.21.0",
26
36
  "typescript": "^5.9.3",
27
- "vitest": "^4.1.5"
37
+ "vitest": "^4.1.10",
38
+ "node": "runtime:^20.17.0"
28
39
  },
29
40
  "scripts": {
30
41
  "build": "kubik build.mts",
@@ -1,6 +1,6 @@
1
+ import type { UserConsoleLog } from 'vitest';
1
2
  import type { SerializedError, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
2
3
  import type { Reporter } from 'vitest/reporters';
3
- import type { UserConsoleLog } from 'vitest';
4
4
  export type FKVitestReporterOptions = {
5
5
  disableUpload?: boolean;
6
6
  flakinessProject?: string;