@akshatmittal/invoker 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/dist/slack.mjs ADDED
@@ -0,0 +1,482 @@
1
+ import { z } from "zod";
2
+ import { WebAPIRateLimitedError, WebClient } from "@slack/web-api";
3
+ import { setTimeout } from "node:timers/promises";
4
+ //#region src/slack/report.ts
5
+ const stringSchema = z.string();
6
+ const jsonObjectSchema = z.record(stringSchema, z.json());
7
+ const invokerMetaSchema = z.strictObject({
8
+ schema: z.literal(1),
9
+ matrix: jsonObjectSchema,
10
+ metadata: jsonObjectSchema.optional(),
11
+ output: z.json().optional()
12
+ });
13
+ const testMetaSchema = z.object({ invoker: invokerMetaSchema.optional() });
14
+ const errorMessageSchema = z.object({ message: stringSchema });
15
+ const errorStackSchema = z.object({ stack: stringSchema });
16
+ const TABLE_ROW_LIMIT = 100;
17
+ const TABLE_CHARACTER_LIMIT = 1e4;
18
+ const SECTION_CHARACTER_LIMIT = 3e3;
19
+ const NAME_CHARACTER_LIMIT = 200;
20
+ const METADATA_CHARACTER_LIMIT = 1800;
21
+ function collectWorkflowReports(modules) {
22
+ const workflows = /* @__PURE__ */ new Map();
23
+ for (const module of modules) for (const testCase of module.children.allTests()) {
24
+ const meta = testMetaSchema.safeParse(testCase.meta());
25
+ const invoker = meta.success ? meta.data.invoker : void 0;
26
+ const taskSuite = testCase.parent;
27
+ const workflowSuite = taskSuite.type === "suite" ? taskSuite.parent : void 0;
28
+ if (!invoker || taskSuite.type !== "suite" || workflowSuite?.type !== "suite") continue;
29
+ let workflow = workflows.get(workflowSuite);
30
+ if (!workflow) {
31
+ workflow = {
32
+ name: workflowSuite.name,
33
+ module,
34
+ suite: workflowSuite,
35
+ metadata: invoker.metadata,
36
+ tasks: /* @__PURE__ */ new Map()
37
+ };
38
+ workflows.set(workflowSuite, workflow);
39
+ }
40
+ let task = workflow.tasks.get(taskSuite);
41
+ if (!task) {
42
+ task = {
43
+ name: taskSuite.name,
44
+ suite: taskSuite,
45
+ total: 0,
46
+ passed: 0,
47
+ retried: 0,
48
+ failed: 0,
49
+ skipped: 0,
50
+ incomplete: 0,
51
+ failures: [],
52
+ retries: []
53
+ };
54
+ workflow.tasks.set(taskSuite, task);
55
+ }
56
+ task.total += 1;
57
+ const result = testCase.result();
58
+ const diagnostic = testCase.diagnostic();
59
+ task[result.state === "pending" ? "incomplete" : result.state] += 1;
60
+ const retryCount = diagnostic?.retryCount ?? 0;
61
+ if (retryCount > 0) {
62
+ task.retried += 1;
63
+ if (result.state === "passed") task.retries.push({
64
+ task: task.name,
65
+ caseName: testCase.name,
66
+ matrix: invoker.matrix,
67
+ count: retryCount,
68
+ messages: (result.errors ?? []).map(errorMessage)
69
+ });
70
+ }
71
+ if (diagnostic) {
72
+ task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
73
+ task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
74
+ }
75
+ if (result.state === "failed") task.failures.push({
76
+ task: task.name,
77
+ caseName: testCase.name,
78
+ matrix: invoker.matrix,
79
+ messages: result.errors.map(errorMessage)
80
+ });
81
+ }
82
+ return [...workflows.values()].map((workflow) => {
83
+ const failures = [];
84
+ const retries = [];
85
+ addErrors(failures, workflow.suite.errors());
86
+ addErrors(failures, workflow.module.errors());
87
+ const collectedTasks = [...workflow.tasks.values()];
88
+ for (const task of collectedTasks) {
89
+ failures.push(...task.failures);
90
+ retries.push(...task.retries);
91
+ addErrors(failures, task.suite.errors(), task.name);
92
+ }
93
+ const { startedAt, endedAt } = timeSpan(collectedTasks);
94
+ return {
95
+ name: workflow.name,
96
+ metadata: workflow.metadata,
97
+ tasks: collectedTasks.map(({ suite: _suite, failures: _failures, retries: _retries, startedAt, endedAt, ...task }) => ({
98
+ ...task,
99
+ duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
100
+ })),
101
+ failures: deduplicateFailures(failures),
102
+ retries,
103
+ startedAt,
104
+ endedAt
105
+ };
106
+ });
107
+ }
108
+ function summaryMessage(reports, runUrl) {
109
+ const timestamp = Math.floor(Date.now() / 1e3);
110
+ const { startedAt, endedAt } = timeSpan(reports);
111
+ const footer = [
112
+ `Elapsed: ${formatDuration(startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt)}`,
113
+ `<!date^${timestamp}^{date_short_pretty} at {time}|${(/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString()}>`,
114
+ ...runUrl ? [`<${escapeSlackControl(runUrl)}|View run>`] : []
115
+ ].join(" • ");
116
+ return {
117
+ text: "Invoker Report",
118
+ attachments: [...reports.flatMap(workflowAttachments), { blocks: [{
119
+ type: "context",
120
+ elements: [{
121
+ type: "mrkdwn",
122
+ text: footer
123
+ }]
124
+ }] }]
125
+ };
126
+ }
127
+ function workflowAttachments(report) {
128
+ const tables = taskTables(report.tasks);
129
+ return tables.map((rows, index) => workflowAttachment(report, rows, index, tables.length));
130
+ }
131
+ function workflowAttachment(report, rows, index, pages) {
132
+ const totals = taskTotals(report.tasks);
133
+ const status = workflowStatus(report);
134
+ const metadata = metadataText(report.metadata);
135
+ const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
136
+ const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${totals.passed}/${totals.total} passed${page}*`;
137
+ return {
138
+ color: status.color,
139
+ blocks: [
140
+ {
141
+ type: "section",
142
+ text: {
143
+ type: "mrkdwn",
144
+ text: headline
145
+ }
146
+ },
147
+ ...metadata ? [{
148
+ type: "context",
149
+ elements: [{
150
+ type: "mrkdwn",
151
+ text: metadata
152
+ }]
153
+ }] : [],
154
+ {
155
+ type: "table",
156
+ column_settings: [
157
+ { is_wrapped: true },
158
+ { align: "right" },
159
+ { align: "right" },
160
+ { align: "right" },
161
+ { align: "right" },
162
+ { align: "right" }
163
+ ],
164
+ rows
165
+ }
166
+ ]
167
+ };
168
+ }
169
+ function failureMessages(report) {
170
+ return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
171
+ const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
172
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
173
+ const metadata = metadataText(report.metadata);
174
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
175
+ return chunk(failures.map((failure) => {
176
+ return `${failure.caseName ? `*${escapeSlack(failure.caseName)}*` : task ? "*Task error*" : "*Workflow error*"}${failure.matrix ? `\nMatrix: ${escapeSlack(JSON.stringify(failure.matrix))}` : ""}${failure.messages.map((message) => `\n• ${escapeSlack(message)}`).join("")}`;
177
+ }), 3e3).map((details, index, messages) => {
178
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
179
+ return {
180
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
181
+ attachments: [{
182
+ color: "danger",
183
+ blocks: [
184
+ {
185
+ type: "section",
186
+ text: {
187
+ type: "mrkdwn",
188
+ text: `🔴 *${title} — ${failures.length} failed${part}*`
189
+ }
190
+ },
191
+ {
192
+ type: "context",
193
+ elements: [{
194
+ type: "mrkdwn",
195
+ text: context
196
+ }]
197
+ },
198
+ {
199
+ type: "section",
200
+ text: {
201
+ type: "mrkdwn",
202
+ text: details
203
+ }
204
+ }
205
+ ]
206
+ }]
207
+ };
208
+ });
209
+ });
210
+ }
211
+ function retryMessages(report) {
212
+ return [...Map.groupBy(report.retries, (retry) => retry.task)].flatMap(([task, retries]) => {
213
+ const title = escapeSlack(truncate(task, NAME_CHARACTER_LIMIT));
214
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
215
+ const metadata = metadataText(report.metadata);
216
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
217
+ return chunk(retries.map((retry) => {
218
+ const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
219
+ const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
220
+ return `*${escapeSlack(retry.caseName)}*\nRetries: ${retry.count}${matrix}${messages}`;
221
+ }), SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
222
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
223
+ return {
224
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task, NAME_CHARACTER_LIMIT)} — ${retries.length} retried${part}`,
225
+ attachments: [{
226
+ color: "warning",
227
+ blocks: [
228
+ {
229
+ type: "section",
230
+ text: {
231
+ type: "mrkdwn",
232
+ text: `🟡 *${title} — ${retries.length} retried${part}*`
233
+ }
234
+ },
235
+ {
236
+ type: "context",
237
+ elements: [{
238
+ type: "mrkdwn",
239
+ text: context
240
+ }]
241
+ },
242
+ {
243
+ type: "section",
244
+ text: {
245
+ type: "mrkdwn",
246
+ text: details
247
+ }
248
+ }
249
+ ]
250
+ }]
251
+ };
252
+ });
253
+ });
254
+ }
255
+ function unhandledErrorMessages(errors) {
256
+ const messages = [...new Set(errors.map(errorMessage))];
257
+ return chunk(messages.map((message) => `• ${escapeSlack(message)}`), SECTION_CHARACTER_LIMIT).map((details, index, chunks) => {
258
+ const part = chunks.length > 1 ? ` (${index + 1}/${chunks.length})` : "";
259
+ return {
260
+ text: `Invoker run — ${messages.length} unhandled error${messages.length === 1 ? "" : "s"}${part}`,
261
+ attachments: [{
262
+ color: "danger",
263
+ blocks: [{
264
+ type: "section",
265
+ text: {
266
+ type: "mrkdwn",
267
+ text: `🔴 *Run errors${part}*`
268
+ }
269
+ }, {
270
+ type: "section",
271
+ text: {
272
+ type: "mrkdwn",
273
+ text: details
274
+ }
275
+ }]
276
+ }]
277
+ };
278
+ });
279
+ }
280
+ function addErrors(failures, errors, task) {
281
+ const messages = errors.map(errorMessage);
282
+ if (messages.length > 0) failures.push({
283
+ task,
284
+ messages
285
+ });
286
+ }
287
+ function errorMessage(cause) {
288
+ const message = errorMessageSchema.safeParse(cause);
289
+ if (message.success) return message.data.message;
290
+ const stack = errorStackSchema.safeParse(cause);
291
+ if (stack.success) return stack.data.stack.split("\n", 1)[0];
292
+ return String(cause);
293
+ }
294
+ function deduplicateFailures(failures) {
295
+ const seen = /* @__PURE__ */ new Set();
296
+ return failures.flatMap((failure) => {
297
+ const messages = failure.messages.filter((message) => {
298
+ const key = JSON.stringify([
299
+ failure.task,
300
+ failure.caseName,
301
+ failure.matrix,
302
+ message
303
+ ]);
304
+ if (seen.has(key)) return false;
305
+ seen.add(key);
306
+ return true;
307
+ });
308
+ return messages.length > 0 ? [{
309
+ ...failure,
310
+ messages
311
+ }] : [];
312
+ });
313
+ }
314
+ function escapeSlack(value) {
315
+ return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
316
+ }
317
+ function escapeSlackControl(value) {
318
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
319
+ }
320
+ function taskTotals(tasks) {
321
+ return tasks.reduce((totals, task) => ({
322
+ total: totals.total + task.total,
323
+ passed: totals.passed + task.passed
324
+ }), {
325
+ total: 0,
326
+ passed: 0
327
+ });
328
+ }
329
+ function workflowStatus(report) {
330
+ if (report.failures.length > 0 || report.tasks.some((task) => task.failed > 0)) return {
331
+ emoji: "🔴",
332
+ color: "danger"
333
+ };
334
+ if (report.tasks.some((task) => task.retried > 0 || task.skipped > 0 || task.incomplete > 0)) return {
335
+ emoji: "🟡",
336
+ color: "warning"
337
+ };
338
+ return {
339
+ emoji: "🟢",
340
+ color: "good"
341
+ };
342
+ }
343
+ function metadataText(metadata) {
344
+ if (!metadata || Object.keys(metadata).length === 0) return void 0;
345
+ return truncate(Object.entries(metadata).map(([key, value]) => {
346
+ const string = stringSchema.safeParse(value);
347
+ return `*${escapeSlack(key)}:* ${escapeSlack(string.success ? string.data : JSON.stringify(value))}`;
348
+ }).join(" • "), METADATA_CHARACTER_LIMIT);
349
+ }
350
+ function rawCell(text) {
351
+ return {
352
+ type: "raw_text",
353
+ text
354
+ };
355
+ }
356
+ function taskTables(tasks) {
357
+ const header = [
358
+ "Task",
359
+ "Passed",
360
+ "Retries",
361
+ "Failed",
362
+ "Skipped",
363
+ "Time"
364
+ ].map(rawCell);
365
+ const headerCharacters = rowCharacters(header);
366
+ const tables = [];
367
+ let rows = [header];
368
+ let characters = headerCharacters;
369
+ for (const task of tasks) {
370
+ const values = [
371
+ String(task.passed),
372
+ String(task.retried),
373
+ String(task.failed),
374
+ String(task.skipped),
375
+ formatDuration(task.duration)
376
+ ];
377
+ const taskNameLimit = TABLE_CHARACTER_LIMIT - headerCharacters - values.reduce((total, value) => total + value.length, 0);
378
+ const row = [truncate(task.name, taskNameLimit), ...values].map(rawCell);
379
+ const rowLength = rowCharacters(row);
380
+ if (rows.length === TABLE_ROW_LIMIT || characters + rowLength > TABLE_CHARACTER_LIMIT) {
381
+ tables.push(rows);
382
+ rows = [header];
383
+ characters = headerCharacters;
384
+ }
385
+ rows.push(row);
386
+ characters += rowLength;
387
+ }
388
+ tables.push(rows);
389
+ return tables;
390
+ }
391
+ function rowCharacters(row) {
392
+ return row.reduce((total, cell) => total + cell.text.length, 0);
393
+ }
394
+ function formatDuration(milliseconds) {
395
+ if (milliseconds < 1e3) return `${Math.round(milliseconds)}ms`;
396
+ return `${(milliseconds / 1e3).toFixed(1)}s`;
397
+ }
398
+ function chunk(entries, limit) {
399
+ const chunks = [];
400
+ for (const entry of entries) {
401
+ const value = entry.length > limit ? `${entry.slice(0, limit - 1)}…` : entry;
402
+ const previous = chunks.at(-1);
403
+ if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
404
+ else chunks.push(value);
405
+ }
406
+ return chunks;
407
+ }
408
+ function truncate(value, limit) {
409
+ return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
410
+ }
411
+ function timeSpan(values) {
412
+ let startedAt;
413
+ let endedAt;
414
+ for (const value of values) {
415
+ if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
416
+ if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
417
+ }
418
+ return {
419
+ startedAt,
420
+ endedAt
421
+ };
422
+ }
423
+ //#endregion
424
+ //#region src/slack/reporter.ts
425
+ function slackReporter(options) {
426
+ const client = new WebClient(options.token, {
427
+ rejectRateLimitedCalls: true,
428
+ retryConfig: { retries: 0 },
429
+ timeout: 1e4
430
+ });
431
+ const postMessage = async (arguments_) => {
432
+ try {
433
+ return await client.chat.postMessage(arguments_);
434
+ } catch (error) {
435
+ if (!(error instanceof WebAPIRateLimitedError)) throw error;
436
+ await setTimeout(error.retryAfter * 1e3);
437
+ return client.chat.postMessage(arguments_);
438
+ }
439
+ };
440
+ return { async onTestRunEnd(modules, unhandledErrors) {
441
+ const reports = collectWorkflowReports(modules);
442
+ if (reports.length === 0) return;
443
+ const parentMessage = summaryMessage(reports, options.runUrl);
444
+ let parentTimestamp;
445
+ try {
446
+ const parentArguments = {
447
+ channel: options.channel,
448
+ ...parentMessage,
449
+ mrkdwn: false,
450
+ unfurl_links: false
451
+ };
452
+ parentTimestamp = (await postMessage(parentArguments)).ts;
453
+ } catch {
454
+ console.warn("[invoker] Could not post the Slack report.");
455
+ return;
456
+ }
457
+ const replies = [
458
+ ...reports.flatMap(failureMessages),
459
+ ...reports.flatMap(retryMessages),
460
+ ...unhandledErrorMessages(unhandledErrors)
461
+ ];
462
+ if (replies.length > 0 && !parentTimestamp) {
463
+ console.warn("[invoker] Slack did not return a timestamp for the report thread.");
464
+ return;
465
+ }
466
+ let failedMessages = 0;
467
+ for (const reply of replies) try {
468
+ await postMessage({
469
+ channel: options.channel,
470
+ ...reply,
471
+ thread_ts: parentTimestamp,
472
+ mrkdwn: false,
473
+ unfurl_links: false
474
+ });
475
+ } catch {
476
+ failedMessages += 1;
477
+ }
478
+ if (failedMessages > 0) console.warn(`[invoker] Could not post ${failedMessages} Slack report message${failedMessages === 1 ? "" : "s"}.`);
479
+ } };
480
+ }
481
+ //#endregion
482
+ export { slackReporter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshatmittal/invoker",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed matrix-driven regression workflows for Vitest.",
5
5
  "keywords": [
6
6
  "matrix",
@@ -27,11 +27,27 @@
27
27
  ".": {
28
28
  "types": "./dist/index.d.mts",
29
29
  "import": "./dist/index.mjs"
30
+ },
31
+ "./github": {
32
+ "types": "./dist/github.d.mts",
33
+ "import": "./dist/github.mjs"
34
+ },
35
+ "./slack": {
36
+ "types": "./dist/slack.d.mts",
37
+ "import": "./dist/slack.mjs"
30
38
  }
31
39
  },
32
40
  "publishConfig": {
33
41
  "access": "public"
34
42
  },
43
+ "dependencies": {
44
+ "@octokit/auth-app": "^8.3.0",
45
+ "@octokit/request": "^10.0.13",
46
+ "@slack/web-api": "^8.0.0",
47
+ "croner": "^10.0.1",
48
+ "evlog": "^2.26.0",
49
+ "zod": "^4.4.3"
50
+ },
35
51
  "devDependencies": {
36
52
  "@types/node": "^24.13.3",
37
53
  "tsdown": "^0.22.14",
@@ -42,6 +58,11 @@
42
58
  "peerDependencies": {
43
59
  "vitest": "^4.1.10"
44
60
  },
61
+ "peerDependenciesMeta": {
62
+ "vitest": {
63
+ "optional": true
64
+ }
65
+ },
45
66
  "engines": {
46
67
  "node": "^24.18.1"
47
68
  },