@kinora/vitest-reporter 0.1.0 → 0.3.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 CHANGED
@@ -37,6 +37,13 @@ Create an API token in the kinora dashboard (Settings → Workspace). Self-hosti
37
37
 
38
38
  Only browser tests are uploaded by default: Node unit tests are usually thousands of fast tests with no trace, and would eat your plan's monthly result quota. Upload them too with `include: 'all'`.
39
39
 
40
+ ## Accessibility
41
+
42
+ With Storybook's a11y addon, each story's check is uploaded too: the rules broken, their impact
43
+ and the elements they hit, ranked by component on the Components page. Use the addon's `todo`
44
+ mode to keep the impact: in `error` mode, Storybook drops the check when the story fails and
45
+ kinora reads the violations from the failure instead.
46
+
40
47
  ## Documentation
41
48
 
42
49
  **https://docs.kinora.dev/guides/vitest/**
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@ import { Reporter, TestModule, Vitest } from "vitest/node";
4
4
  type IncludeTests = 'browser' | 'all';
5
5
  //#endregion
6
6
  //#region src/index.d.ts
7
- interface KinoraVitestReporterOptions {
7
+ export interface KinoraVitestReporterOptions {
8
8
  /** kinora server base URL. Defaults to env KINORA_URL, then the hosted cloud. Set for self-host. */
9
9
  url?: string;
10
10
  /** Project API token. Defaults to env KINORA_TOKEN (keep it out of the config file). */
@@ -24,14 +24,22 @@ interface KinoraVitestReporterOptions {
24
24
  * plan's monthly result quota with large unit suites.
25
25
  */
26
26
  include?: IncludeTests;
27
+ /**
28
+ * Sharded runs (`--shard`): the uploads of every shard of a CI run are assembled into a single
29
+ * kinora run. Shards are grouped by this key, defaulting to env KINORA_GROUP_KEY, then the CI run
30
+ * (GITHUB_RUN_ID / CI_PIPELINE_ID). Set it when a matrix runs the sharded suite once per leg, to
31
+ * tell the legs apart. `false` uploads each shard as its own run.
32
+ */
33
+ groupKey?: string | false;
27
34
  }
28
- declare class KinoraVitestReporter implements Reporter {
35
+ export default class KinoraVitestReporter implements Reporter {
29
36
  private readonly options;
30
37
  private startedAt;
38
+ private shard;
31
39
  constructor(options: KinoraVitestReporterOptions);
32
40
  onInit(vitest: Vitest): void;
33
41
  onTestRunStart(): void;
34
42
  onTestRunEnd(modules: ReadonlyArray<TestModule>): Promise<void>;
35
43
  }
36
44
  //#endregion
37
- export { KinoraVitestReporterOptions, KinoraVitestReporter as default };
45
+ export {};
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import process from "node:process";
3
- import { DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, IngestError, STORY_ANNOTATION, createIngestClient, detectCiEnv, isUploadableAttachment, makeTestKey, stripAnsi } from "@kinora/core";
3
+ import { DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, IngestError, MAX_A11Y_NODES, MAX_A11Y_VIOLATIONS, STORY_ANNOTATION, axeViolations, createIngestClient, detectCiEnv, isUploadableAttachment, makeTestKey, resolveShardGroup, stripAnsi } from "@kinora/core";
4
4
  //#region src/convert.ts
5
5
  function identity(test) {
6
6
  const file = test.module.relativeModuleId;
@@ -28,6 +28,49 @@ function storyOf(test, titlePath) {
28
28
  if (!meta.componentName) return meta.storyId.replace("--", "/");
29
29
  return `${meta.componentName}/${titlePath[1]}`;
30
30
  }
31
+ const MAX_TEXT = 300;
32
+ const truncate = (s) => s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT)}…` : s;
33
+ const AXE_FAILURE = /Expected the HTML found at \$\('(.+?)'\) to have no violations:[\s\S]*?Received:\s*"(.+?) \(([\w-]+)\)"[\s\S]*?more information on this issue here:\s*(https:\/\/\S+)/g;
34
+ function a11yFromError(test) {
35
+ const message = test.result().errors?.find((e) => e.message.includes("toHaveNoViolations"))?.message;
36
+ if (!message) return void 0;
37
+ const byRule = /* @__PURE__ */ new Map();
38
+ for (const [, target, help, id, helpUrl] of stripAnsi(message).matchAll(AXE_FAILURE)) {
39
+ const v = byRule.get(id) ?? {
40
+ id,
41
+ help: truncate(help),
42
+ helpUrl,
43
+ nodes: [],
44
+ nodeCount: 0
45
+ };
46
+ if (v.nodes.length < MAX_A11Y_NODES) v.nodes.push(truncate(target));
47
+ v.nodeCount++;
48
+ byRule.set(id, v);
49
+ }
50
+ return byRule.size ? {
51
+ status: "failed",
52
+ violations: [...byRule.values()].slice(0, MAX_A11Y_VIOLATIONS)
53
+ } : void 0;
54
+ }
55
+ function a11yOf(test) {
56
+ const report = test.meta().reports?.find((r) => r.type === "a11y");
57
+ const status = report?.status;
58
+ if (status !== "passed" && status !== "warning" && status !== "failed") return a11yFromError(test);
59
+ const result = report?.result ?? {};
60
+ if (result.error) {
61
+ const { message } = result.error;
62
+ const text = typeof message === "string" ? message : typeof result.error === "string" ? result.error : "The accessibility check failed.";
63
+ return {
64
+ status: "failed",
65
+ violations: [],
66
+ error: truncate(text)
67
+ };
68
+ }
69
+ return {
70
+ status,
71
+ violations: axeViolations(result.violations)
72
+ };
73
+ }
31
74
  function slug(name) {
32
75
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "screenshot";
33
76
  }
@@ -75,6 +118,7 @@ function toNormTest(test) {
75
118
  const { file, titlePath, projectName } = identity(test);
76
119
  const diagnostic = test.diagnostic();
77
120
  const story = storyOf(test, titlePath);
121
+ const a11y = a11yOf(test);
78
122
  return {
79
123
  testKey: makeTestKey(file, titlePath, projectName),
80
124
  title: test.name,
@@ -99,7 +143,8 @@ function toNormTest(test) {
99
143
  message: cleanMessage(e.message),
100
144
  stack: e.stack
101
145
  })),
102
- attachments: attachmentsOf(test)
146
+ attachments: attachmentsOf(test),
147
+ ...a11y && { a11y }
103
148
  };
104
149
  }
105
150
  function toNormTests(modules, include = "browser") {
@@ -134,11 +179,17 @@ function countsOf(tests) {
134
179
  var KinoraVitestReporter = class {
135
180
  options;
136
181
  startedAt = /* @__PURE__ */ new Date();
182
+ shard;
137
183
  constructor(options) {
138
184
  this.options = options;
139
185
  }
140
186
  onInit(vitest) {
141
187
  this.startedAt = /* @__PURE__ */ new Date();
188
+ const shard = vitest.config.shard;
189
+ this.shard = shard ? {
190
+ current: shard.index,
191
+ total: shard.count
192
+ } : void 0;
142
193
  vitest.config.includeTaskLocation = true;
143
194
  for (const project of vitest.projects) project.config.includeTaskLocation = true;
144
195
  }
@@ -153,9 +204,13 @@ var KinoraVitestReporter = class {
153
204
  return;
154
205
  }
155
206
  const { tests, skippedNodeTests } = toNormTests(modules, this.options.include);
207
+ const group = this.shard && this.options.groupKey !== false ? resolveShardGroup(process.env, { groupKey: this.options.groupKey }) : void 0;
156
208
  if (!tests.length) {
157
- console.warn(skippedNodeTests ? `[kinora] nothing uploaded: no browser tests in this run (${skippedNodeTests} Node tests left out). Set include: 'all' to upload Node tests too.` : "[kinora] nothing uploaded: no tests ran");
158
- return;
209
+ if (!group) {
210
+ console.warn(skippedNodeTests ? `[kinora] nothing uploaded: no browser tests in this run (${skippedNodeTests} Node tests left out). Set include: 'all' to upload Node tests too.` : "[kinora] nothing uploaded: no tests ran");
211
+ return;
212
+ }
213
+ console.warn("[kinora] no tests to upload in this shard: filling its slot so the run can complete");
159
214
  }
160
215
  const detected = detectCiEnv(process.env);
161
216
  const payload = {
@@ -168,7 +223,11 @@ var KinoraVitestReporter = class {
168
223
  duration: Date.now() - this.startedAt.getTime(),
169
224
  counts: countsOf(tests),
170
225
  git: this.options.git ?? detected.git,
171
- ci: this.options.ci ?? detected.ci
226
+ ci: this.options.ci ?? detected.ci,
227
+ ...this.shard && group && {
228
+ ...group,
229
+ shard: this.shard
230
+ }
172
231
  },
173
232
  tests
174
233
  };
@@ -195,7 +254,7 @@ var KinoraVitestReporter = class {
195
254
  console.warn(`[kinora] ${a.name} upload failed for ${t.testKey}:`, err instanceof Error ? err.message : err);
196
255
  }
197
256
  }
198
- console.log(`[kinora] uploaded ${res.tests} tests + ${artifacts} artifacts (run ${res.runId})`);
257
+ console.log(`[kinora] uploaded ${res.tests} tests + ${artifacts} artifacts (run ${res.runId}${res.shardProgress ? `, ${res.shardProgress.landed} of ${res.shardProgress.total} shards` : ""})`);
199
258
  } catch (err) {
200
259
  if (err instanceof IngestError && err.status === 402) console.warn(`[kinora] ${err.message}`);
201
260
  else console.error("[kinora] upload failed:", err instanceof Error ? err.message : err);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kinora/vitest-reporter",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.3.0",
5
5
  "description": "Vitest reporter that uploads browser-mode and Storybook test results to a kinora server",
6
6
  "author": "Joris Gallot",
7
7
  "license": "MIT",
@@ -28,23 +28,23 @@
28
28
  "dist"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=20"
31
+ "node": ">=20.20.2"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
35
35
  },
36
36
  "peerDependencies": {
37
- "vitest": ">=4.1.0"
37
+ "vitest": ">=4.1.12"
38
38
  },
39
39
  "dependencies": {
40
- "@kinora/core": "0.7.0"
40
+ "@kinora/core": "0.13.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "^26.5.1",
44
- "@vitest/coverage-v8": "^5.0.3",
45
- "tsdown": "^0.22.14",
43
+ "@types/node": "^26.6.5",
44
+ "@vitest/coverage-v8": "^5.0.5",
45
+ "tsdown": "^0.23.1",
46
46
  "typescript": "~6.0.3",
47
- "vitest": "^5.0.3"
47
+ "vitest": "^5.0.5"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsdown",