@akshatmittal/invoker 0.1.0 → 0.2.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
@@ -10,7 +10,91 @@ validated JSON Output in Vitest metadata for reporters and later analysis.
10
10
  pnpm add -D @akshatmittal/invoker vitest
11
11
  ```
12
12
 
13
- Invoker supports Node 24 and Vitest 4.1.10 or newer within Vitest 4.
13
+ Invoker supports Node 24.18.1 or newer within Node 24 and Vitest 4.1.10 or newer within Vitest 4.
14
+
15
+ ## Schedule GitHub Actions
16
+
17
+ `@akshatmittal/invoker/github` runs code-defined GitHub Actions schedules from
18
+ a small, long-running Node process. It is independent from the Vitest SDK, so a
19
+ scheduler-only installation does not need Vitest.
20
+
21
+ Create a GitHub App with repository **Actions: read and write**, disable its
22
+ webhook, install it on the selected repositories, and generate a private key.
23
+ No other repository, organization, user, OAuth, or webhook permissions are
24
+ needed.
25
+
26
+ Install the scheduler with t3-env and Zod in a plain ESM application:
27
+
28
+ ```sh
29
+ npm install @akshatmittal/invoker @t3-oss/env-core zod
30
+ ```
31
+
32
+ ```js
33
+ // schedule.mjs
34
+ import { createEnv } from "@t3-oss/env-core";
35
+ import { defineGitHubSchedule } from "@akshatmittal/invoker/github";
36
+ import { z } from "zod";
37
+
38
+ const env = createEnv({
39
+ server: {
40
+ GITHUB_APP_ID: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER),
41
+ GITHUB_APP_PRIVATE_KEY: z.string().min(1),
42
+ },
43
+ runtimeEnv: process.env,
44
+ });
45
+
46
+ await defineGitHubSchedule({
47
+ app: {
48
+ id: env.GITHUB_APP_ID,
49
+ privateKey: env.GITHUB_APP_PRIVATE_KEY,
50
+ },
51
+ schedules: [
52
+ {
53
+ cron: "0 9 * * 1",
54
+ timezone: "UTC",
55
+ repository: "acme/regressions",
56
+ workflow: "invoker.yml",
57
+ ref: "main",
58
+ inputs: { dataset: "weekly", publish: true },
59
+ },
60
+ ],
61
+ });
62
+ ```
63
+
64
+ Schedules use five-field cron expressions and default to UTC when `timezone`
65
+ is omitted. Configuration is fixed at startup. Every repository installation
66
+ and active workflow is validated before timers begin; GitHub validates the ref,
67
+ `workflow_dispatch` declaration, and input schema when a Dispatch is due.
68
+
69
+ Run exactly one replica. Dispatches may overlap, are not persisted or retried,
70
+ and failures do not stop later occurrences. The process emits events through
71
+ evlog's shared logger, so the host's filtering, sampling, redaction, and drain
72
+ configuration applies. The module does not initialize or configure evlog.
73
+ `SIGINT` and `SIGTERM` stop new Dispatches, await in-flight requests, and
74
+ resolve the long-running promise.
75
+
76
+ ### Docker
77
+
78
+ Keep `package.json`, `package-lock.json`, and `schedule.mjs` in a deployment
79
+ directory and build this image:
80
+
81
+ ```dockerfile
82
+ FROM node:24-slim
83
+
84
+ WORKDIR /app
85
+ COPY package.json package-lock.json ./
86
+ RUN npm ci --omit=dev
87
+ COPY schedule.mjs ./
88
+
89
+ USER node
90
+ CMD ["node", "schedule.mjs"]
91
+ ```
92
+
93
+ Exclude `.env`, PEM, and private-key files from the build context. Inject
94
+ `GITHUB_APP_ID` and the real multiline `GITHUB_APP_PRIVATE_KEY` through the
95
+ runtime platform's secret mechanism; never bake them into the image. Use the
96
+ platform's process-liveness check and restart policy with one replica. The
97
+ scheduler intentionally has no HTTP health endpoint.
14
98
 
15
99
  ## Define a Workflow
16
100
 
@@ -41,9 +125,9 @@ export const evaluateModels = defineTask({
41
125
  ```
42
126
 
43
127
  ```ts
44
- // regressions/model/index.test.ts
128
+ // regressions/workflows/model-regressions.test.ts
45
129
  import { defineWorkflow } from "@akshatmittal/invoker";
46
- import { evaluateModels } from "./tasks/evaluate-models.js";
130
+ import { evaluateModels } from "../tasks/evaluate-models.js";
47
131
 
48
132
  defineWorkflow({
49
133
  name: "model-regressions",
@@ -57,14 +141,15 @@ defineWorkflow({
57
141
 
58
142
  Matrix literals determine the exact `matrix` type, `setup` determines the exact
59
143
  shared setup type, and the exact JSON return type is retained on the Task.
144
+ Axis names must be non-empty, enumerable strings that are not array indexes.
60
145
  Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
61
146
  within that Task run concurrently, and teardown runs once after successful
62
147
  setup. Tasks run sequentially in their Workflow.
63
148
 
64
149
  ## Configure Vitest
65
150
 
66
- Invoker uses Vitest's built-in reporters. The JSON reporter includes each
67
- Case's data at `assertionResults[].meta.invoker`:
151
+ The JSON reporter includes each Case's data at
152
+ `assertionResults[].meta.invoker`:
68
153
 
69
154
  ```ts
70
155
  // vitest.config.ts
@@ -85,9 +170,13 @@ Run every Workflow or filter to one Task with ordinary Vitest commands:
85
170
 
86
171
  ```sh
87
172
  pnpm vitest run
88
- pnpm vitest run regressions/model/index.test.ts -t evaluate-models
173
+ pnpm vitest run regressions/workflows/model-regressions.test.ts -t evaluate-models
89
174
  ```
90
175
 
176
+ Define additional Workflows in separate `*.test.ts` files. Vitest discovers
177
+ them automatically; Invoker does not scan directories or require a central
178
+ index.
179
+
91
180
  The metadata envelope is stable and JSON-compatible:
92
181
 
93
182
  ```json
@@ -103,6 +192,48 @@ The metadata envelope is stable and JSON-compatible:
103
192
  report remains authoritative for status, failures, timing, hierarchy, and
104
193
  retries.
105
194
 
195
+ ## Notify Slack
196
+
197
+ Invoker's optional Slack reporter posts one `Invoker Report` parent message per
198
+ Vitest run. It places additional Workflow cards in the message thread so every
199
+ Task table remains within Slack's row and character limits. Each card includes
200
+ aggregate results, Workflow metadata, and a table of Task counts and durations.
201
+ A shared footer contains the elapsed span from the first Case start to the final
202
+ Case completion, a localized timestamp, and the optional run link. Final
203
+ failures are posted in the same thread with one reply per failed Task in each
204
+ Workflow. Unhandled run errors are reported once. Delivery failures are
205
+ isolated to the affected reply. Ambiguous transport failures are not retried;
206
+ an explicit Slack rate-limit rejection is reattempted only after its required
207
+ delay.
208
+
209
+ ```ts
210
+ import { slackReporter } from "@akshatmittal/invoker/slack";
211
+ import { defineConfig } from "vitest/config";
212
+
213
+ const runUrl =
214
+ process.env.GITHUB_ACTIONS === "true"
215
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
216
+ : undefined;
217
+
218
+ export default defineConfig({
219
+ test: {
220
+ reporters: [
221
+ "tree",
222
+ slackReporter({
223
+ token: process.env.SLACK_BOT_TOKEN!,
224
+ channel: process.env.SLACK_CHANNEL_ID!,
225
+ runUrl,
226
+ }),
227
+ ],
228
+ },
229
+ });
230
+ ```
231
+
232
+ Create a Slack app with the `chat:write` bot scope, install it to the workspace,
233
+ and expose its bot token and target channel ID as `SLACK_BOT_TOKEN` and
234
+ `SLACK_CHANNEL_ID`. Invite the bot to private target channels. Slack delivery
235
+ failures produce a warning but do not change Vitest's exit status.
236
+
106
237
  ## GitHub Actions artifacts
107
238
 
108
239
  Create the output directory before Vitest and upload the report even when the
@@ -122,7 +253,7 @@ queries or reports. Invoker does not upload, index, or persist results itself.
122
253
 
123
254
  ## v1 scope
124
255
 
125
- Invoker does not provide a CLI, directory discovery, custom runner, custom
126
- reporter, configuration helper, Task-level parallelism, matrix include/exclude,
127
- or hosted result storage. Use Vitest configuration and your CI runner for those
128
- concerns.
256
+ Invoker does not provide a CLI, directory discovery, custom runner, general
257
+ reporter framework, configuration helper, Task-level parallelism, matrix
258
+ include/exclude, or hosted result storage. Use Vitest configuration and your CI
259
+ runner for those concerns.
@@ -0,0 +1,22 @@
1
+ //#region src/github/types.d.ts
2
+ type WorkflowInput = string | number | boolean;
3
+ type GitHubSchedule = {
4
+ readonly cron: string;
5
+ readonly timezone?: string;
6
+ readonly repository: string;
7
+ readonly workflow: string | number;
8
+ readonly ref: string;
9
+ readonly inputs?: Readonly<Record<string, WorkflowInput>>;
10
+ };
11
+ type GitHubScheduleDefinition = {
12
+ readonly app: {
13
+ readonly id: number;
14
+ readonly privateKey: string;
15
+ };
16
+ readonly schedules: readonly [GitHubSchedule, ...GitHubSchedule[]];
17
+ };
18
+ //#endregion
19
+ //#region src/github.d.ts
20
+ declare function defineGitHubSchedule(definition: GitHubScheduleDefinition): Promise<void>;
21
+ //#endregion
22
+ export { defineGitHubSchedule };
@@ -0,0 +1,353 @@
1
+ import { z } from "zod";
2
+ import { Cron } from "croner";
3
+ import { createAppAuth } from "@octokit/auth-app";
4
+ import { request } from "@octokit/request";
5
+ import { log } from "evlog";
6
+ //#region src/github/client.ts
7
+ const API_VERSION = "2026-03-10";
8
+ const dispatchResponseSchema = z.object({
9
+ workflow_run_id: z.int().positive(),
10
+ html_url: z.url()
11
+ });
12
+ const requestFailureSchema = z.object({
13
+ status: z.int().optional(),
14
+ response: z.object({ headers: z.object({ "x-github-request-id": z.string().optional() }) }).optional()
15
+ });
16
+ const githubFailures = /* @__PURE__ */ new WeakMap();
17
+ function createGitHubClient(app, signal) {
18
+ const apiRequest = request.defaults({
19
+ headers: {
20
+ accept: "application/vnd.github+json",
21
+ "x-github-api-version": API_VERSION
22
+ },
23
+ request: { signal }
24
+ });
25
+ const auth = createAppAuth({
26
+ appId: app.id,
27
+ privateKey: app.privateKey,
28
+ request: apiRequest,
29
+ log: { warn() {} }
30
+ });
31
+ const resolveInstallation = async (target) => {
32
+ try {
33
+ const authentication = await auth({ type: "app" });
34
+ return (await apiRequest("GET /repos/{owner}/{repo}/installation", {
35
+ owner: target.owner,
36
+ repo: target.repo,
37
+ headers: { authorization: `Bearer ${authentication.token}` }
38
+ })).data.id;
39
+ } catch (error) {
40
+ throw githubError(githubFailure(error, "resolve installation", target));
41
+ }
42
+ };
43
+ const installationToken = async (target, installationId) => {
44
+ try {
45
+ return (await auth({
46
+ type: "installation",
47
+ installationId,
48
+ repositoryNames: [target.repo],
49
+ permissions: { actions: "write" }
50
+ })).token;
51
+ } catch (error) {
52
+ throw githubError(githubFailure(error, "create installation token", target));
53
+ }
54
+ };
55
+ const resolveWorkflow = async (target, installationId) => {
56
+ try {
57
+ const token = await installationToken(target, installationId);
58
+ const response = await apiRequest("GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}", {
59
+ owner: target.owner,
60
+ repo: target.repo,
61
+ workflow_id: target.workflow,
62
+ headers: { authorization: `Bearer ${token}` }
63
+ });
64
+ if (response.data.state !== "active") throw new Error("workflow is not active");
65
+ return response.data.id;
66
+ } catch (error) {
67
+ throw githubError(githubFailure(error, "resolve workflow", target));
68
+ }
69
+ };
70
+ const dispatch = async (target) => {
71
+ try {
72
+ const token = await installationToken(target, target.installationId);
73
+ const response = await apiRequest("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
74
+ owner: target.owner,
75
+ repo: target.repo,
76
+ workflow_id: target.workflowId,
77
+ ref: target.ref,
78
+ return_run_details: true,
79
+ inputs: target.inputs,
80
+ headers: { authorization: `Bearer ${token}` }
81
+ });
82
+ const data = dispatchResponseSchema.parse(response.data);
83
+ return {
84
+ runId: data.workflow_run_id,
85
+ webUrl: data.html_url
86
+ };
87
+ } catch (error) {
88
+ throw githubError(githubFailure(error, "dispatch workflow", target));
89
+ }
90
+ };
91
+ return {
92
+ dispatch,
93
+ resolveInstallation,
94
+ resolveWorkflow
95
+ };
96
+ }
97
+ function githubFailure(cause, operation, target) {
98
+ if (cause instanceof Error) {
99
+ const known = githubFailures.get(cause);
100
+ if (known) return known;
101
+ }
102
+ const parsed = requestFailureSchema.safeParse(cause);
103
+ const status = parsed.success ? parsed.data.status : void 0;
104
+ const requestId = parsed.success ? parsed.data.response?.headers["x-github-request-id"] : void 0;
105
+ return {
106
+ message: `GitHub ${operation}${target ? ` for ${target.repository} workflow ${String(target.workflow)}` : ""}: ${status === 404 ? "not found or inaccessible" : "request failed"}${status === void 0 ? "" : ` (${status})`}${requestId === void 0 ? "" : ` [request ${requestId}]`}`,
107
+ operation,
108
+ repository: target?.repository,
109
+ workflow: target?.workflow,
110
+ status,
111
+ requestId
112
+ };
113
+ }
114
+ function githubError(failure) {
115
+ const error = new Error(failure.message);
116
+ githubFailures.set(error, failure);
117
+ return error;
118
+ }
119
+ //#endregion
120
+ //#region src/github/config.ts
121
+ const GITHUB_SCHEDULE_OWNER = "GitHub Schedule";
122
+ const inputSchema = z.union([
123
+ z.string(),
124
+ z.number().finite(),
125
+ z.boolean()
126
+ ]);
127
+ const timezoneSchema = z.string().refine((timezone) => {
128
+ try {
129
+ new Intl.DateTimeFormat(void 0, { timeZone: timezone });
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }, "expected a valid IANA timezone");
135
+ const scheduleSchema = z.strictObject({
136
+ cron: z.string().min(1),
137
+ timezone: timezoneSchema.default("UTC"),
138
+ repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/),
139
+ workflow: z.union([z.int().positive(), z.string().min(1).refine((value) => value.trim() === value && !value.includes("/"))]),
140
+ ref: z.string().refine((value) => value.trim() !== ""),
141
+ inputs: z.record(z.string(), inputSchema).refine((inputs) => Object.keys(inputs).length <= 25).refine((inputs) => JSON.stringify(inputs).length <= 65535).optional()
142
+ });
143
+ const definitionSchema = z.strictObject({
144
+ app: z.strictObject({
145
+ id: z.int().positive(),
146
+ privateKey: z.string().min(1)
147
+ }),
148
+ schedules: z.array(scheduleSchema).min(1)
149
+ });
150
+ function normalizeDefinition(value) {
151
+ const result = definitionSchema.safeParse(value);
152
+ if (!result.success) throw new TypeError(`${GITHUB_SCHEDULE_OWNER}: ${z.prettifyError(result.error)}`);
153
+ const schedules = result.data.schedules.map((schedule, index) => {
154
+ let validationJob;
155
+ try {
156
+ validationJob = new Cron(schedule.cron, {
157
+ mode: "5-part",
158
+ paused: true,
159
+ timezone: schedule.timezone
160
+ });
161
+ validationJob.nextRun();
162
+ } catch {
163
+ throw new TypeError(`${GITHUB_SCHEDULE_OWNER}.schedules[${index}].cron: expected a valid five-field cron expression`);
164
+ } finally {
165
+ validationJob?.stop();
166
+ }
167
+ const separator = schedule.repository.indexOf("/");
168
+ const owner = schedule.repository.slice(0, separator);
169
+ const repo = schedule.repository.slice(separator + 1);
170
+ return {
171
+ ...schedule,
172
+ owner,
173
+ repo
174
+ };
175
+ });
176
+ return {
177
+ app: result.data.app,
178
+ schedules
179
+ };
180
+ }
181
+ function workflowKey(schedule) {
182
+ return `${schedule.repository}\0${String(schedule.workflow)}`;
183
+ }
184
+ //#endregion
185
+ //#region src/github/events.ts
186
+ const SERVICE = "github-schedule";
187
+ function logStartup(schedules, repositories, workflows, failure) {
188
+ log[failure ? "error" : "info"]({
189
+ service: SERVICE,
190
+ event: "github_schedule.startup",
191
+ schedules,
192
+ repositories,
193
+ workflows,
194
+ ...failure ? {
195
+ outcome: "failure",
196
+ failure
197
+ } : { outcome: "success" }
198
+ });
199
+ }
200
+ function logDispatch(schedule, scheduledAt, result, failure) {
201
+ log[failure ? "error" : "info"]({
202
+ service: SERVICE,
203
+ event: "github_schedule.dispatch",
204
+ repository: schedule.repository,
205
+ workflow: schedule.workflow,
206
+ ref: schedule.ref,
207
+ cron: schedule.cron,
208
+ timezone: schedule.timezone,
209
+ scheduledAt,
210
+ ...failure ? {
211
+ outcome: "failure",
212
+ failure
213
+ } : {
214
+ outcome: "success",
215
+ runId: result.runId,
216
+ runUrl: result.webUrl
217
+ }
218
+ });
219
+ }
220
+ function logShutdown(signal, drainedDispatches) {
221
+ log.info({
222
+ service: SERVICE,
223
+ event: "github_schedule.shutdown",
224
+ signal,
225
+ drainedDispatches,
226
+ outcome: "success"
227
+ });
228
+ }
229
+ //#endregion
230
+ //#region src/github/scheduler.ts
231
+ let schedulerActive = false;
232
+ async function runGitHubSchedule(definition) {
233
+ let normalized;
234
+ try {
235
+ normalized = normalizeDefinition(definition);
236
+ } catch (error) {
237
+ logStartup(0, 0, 0, localFailure(error));
238
+ throw error;
239
+ }
240
+ const repositoryCount = new Set(normalized.schedules.map(({ repository }) => repository)).size;
241
+ const workflowCount = new Set(normalized.schedules.map(workflowKey)).size;
242
+ if (schedulerActive) {
243
+ const error = /* @__PURE__ */ new TypeError(`${GITHUB_SCHEDULE_OWNER}: another scheduler is already active`);
244
+ logStartup(normalized.schedules.length, repositoryCount, workflowCount, localFailure(error));
245
+ throw error;
246
+ }
247
+ schedulerActive = true;
248
+ const startupAbort = new AbortController();
249
+ const jobs = [];
250
+ const inFlight = /* @__PURE__ */ new Set();
251
+ let state = "starting";
252
+ let stopSignal;
253
+ let drainedCount = 0;
254
+ let resolveStop;
255
+ const stopRequested = new Promise((resolve) => {
256
+ resolveStop = resolve;
257
+ });
258
+ const isStopping = () => state === "stopping";
259
+ const removeSignalHandlers = () => {
260
+ process.removeListener("SIGINT", onSigint);
261
+ process.removeListener("SIGTERM", onSigterm);
262
+ };
263
+ const requestStop = (signal) => {
264
+ if (isStopping()) return;
265
+ const duringStartup = state === "starting";
266
+ state = "stopping";
267
+ stopSignal = signal;
268
+ drainedCount = inFlight.size;
269
+ jobs.forEach((job) => job.stop());
270
+ removeSignalHandlers();
271
+ if (duringStartup) startupAbort.abort();
272
+ resolveStop();
273
+ };
274
+ function onSigint() {
275
+ requestStop("SIGINT");
276
+ }
277
+ function onSigterm() {
278
+ requestStop("SIGTERM");
279
+ }
280
+ process.on("SIGINT", onSigint);
281
+ process.on("SIGTERM", onSigterm);
282
+ try {
283
+ const github = createGitHubClient(normalized.app, startupAbort.signal);
284
+ const installations = /* @__PURE__ */ new Map();
285
+ for (const schedule of normalized.schedules) if (!installations.has(schedule.repository)) installations.set(schedule.repository, await github.resolveInstallation(schedule));
286
+ const workflows = /* @__PURE__ */ new Map();
287
+ for (const schedule of normalized.schedules) {
288
+ const key = workflowKey(schedule);
289
+ if (!workflows.has(key)) workflows.set(key, await github.resolveWorkflow(schedule, installations.get(schedule.repository)));
290
+ }
291
+ if (isStopping()) {
292
+ await finishShutdown(inFlight, stopSignal, drainedCount);
293
+ return;
294
+ }
295
+ const schedules = normalized.schedules.map((schedule) => ({
296
+ ...schedule,
297
+ installationId: installations.get(schedule.repository),
298
+ workflowId: workflows.get(workflowKey(schedule))
299
+ }));
300
+ for (const schedule of schedules) {
301
+ const job = new Cron(schedule.cron, {
302
+ catch: true,
303
+ mode: "5-part",
304
+ timezone: schedule.timezone
305
+ }, (currentJob) => {
306
+ if (state !== "running") return;
307
+ const scheduledAt = (currentJob.currentRun() ?? /* @__PURE__ */ new Date()).toISOString();
308
+ const dispatch = github.dispatch(schedule).then((result) => logDispatch(schedule, scheduledAt, result), (cause) => {
309
+ const failure = githubFailure(cause, "dispatch workflow", schedule);
310
+ logDispatch(schedule, scheduledAt, void 0, failure);
311
+ throw new Error(failure.message);
312
+ });
313
+ inFlight.add(dispatch);
314
+ dispatch.then(() => inFlight.delete(dispatch), () => inFlight.delete(dispatch));
315
+ return dispatch;
316
+ });
317
+ jobs.push(job);
318
+ }
319
+ state = "running";
320
+ logStartup(normalized.schedules.length, repositoryCount, workflowCount);
321
+ await stopRequested;
322
+ await finishShutdown(inFlight, stopSignal, drainedCount);
323
+ } catch (error) {
324
+ if (isStopping()) {
325
+ await finishShutdown(inFlight, stopSignal, drainedCount);
326
+ return;
327
+ }
328
+ jobs.forEach((job) => job.stop());
329
+ removeSignalHandlers();
330
+ schedulerActive = false;
331
+ const failure = githubFailure(error, "start scheduler");
332
+ logStartup(normalized.schedules.length, repositoryCount, workflowCount, failure);
333
+ throw githubError(failure);
334
+ }
335
+ }
336
+ async function finishShutdown(inFlight, signal, drainedCount) {
337
+ await Promise.allSettled(inFlight);
338
+ schedulerActive = false;
339
+ logShutdown(signal, drainedCount);
340
+ }
341
+ function localFailure(cause) {
342
+ return {
343
+ message: cause instanceof Error ? cause.message : `${GITHUB_SCHEDULE_OWNER}: startup validation failed`,
344
+ operation: "validate configuration"
345
+ };
346
+ }
347
+ //#endregion
348
+ //#region src/github.ts
349
+ function defineGitHubSchedule(definition) {
350
+ return runGitHubSchedule(definition);
351
+ }
352
+ //#endregion
353
+ export { defineGitHubSchedule };
package/dist/index.d.mts CHANGED
@@ -35,10 +35,7 @@ declare const taskDefinitionBrand: unique symbol;
35
35
  interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue> {
36
36
  readonly name: Name;
37
37
  readonly matrix: M;
38
- readonly [taskDefinitionBrand]: {
39
- readonly setup: Setup;
40
- readonly output: Output;
41
- };
38
+ readonly [taskDefinitionBrand]: true;
42
39
  readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
43
40
  readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
44
41
  readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
@@ -62,10 +59,7 @@ declare function defineTask<const Name extends string, const M extends Matrix =
62
59
  type AnyTaskDefinition = {
63
60
  readonly name: string;
64
61
  readonly matrix: Matrix;
65
- readonly [taskDefinitionBrand]: {
66
- readonly setup: unknown;
67
- readonly output: JsonValue;
68
- };
62
+ readonly [taskDefinitionBrand]: true;
69
63
  };
70
64
  //#endregion
71
65
  //#region src/workflow.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { beforeAll, describe, test } from "vitest";
2
+ import { z } from "zod";
2
3
  //#region src/task.ts
3
4
  const taskDefinitionBrand = Symbol("invoker.task");
4
5
  function defineTask(definition) {
@@ -10,72 +11,91 @@ function defineTask(definition) {
10
11
  }
11
12
  //#endregion
12
13
  //#region src/json.ts
13
- function assertJson(value, owner, path, ancestors = /* @__PURE__ */ new Set()) {
14
- if (value === null || typeof value === "string" || typeof value === "boolean") return;
15
- if (typeof value === "number") {
16
- if (!Number.isFinite(value)) fail(owner, path, "expected a finite number");
17
- return;
18
- }
19
- if (typeof value !== "object") fail(owner, path, `expected JSON, received ${typeof value}`);
14
+ const scalarSchema = z.union([
15
+ z.null(),
16
+ z.string(),
17
+ z.boolean(),
18
+ z.number()
19
+ ]);
20
+ const nameSchema = z.string().trim().min(1);
21
+ function snapshotJson(value, owner, path, ancestors = /* @__PURE__ */ new Set()) {
22
+ return cloneJson(value, owner, path, ancestors);
23
+ }
24
+ function cloneJson(value, owner, path, ancestors) {
25
+ if (scalarSchema.safeParse(value).success) return value;
26
+ if (!isJsonContainer(value)) fail(owner, path, "expected JSON");
20
27
  if (ancestors.has(value)) fail(owner, path, "cyclic values are not JSON");
21
28
  ancestors.add(value);
22
- if (Array.isArray(value)) for (let index = 0; index < value.length; index += 1) {
23
- if (!(index in value)) fail(owner, `${path}[${index}]`, "sparse arrays are not JSON");
24
- assertJson(value[index], owner, `${path}[${index}]`, ancestors);
25
- }
26
- else {
29
+ if (Array.isArray(value)) {
30
+ const snapshot = [];
31
+ for (let index = 0; index < value.length; index += 1) {
32
+ if (!(index in value)) fail(owner, `${path}[${index}]`, "sparse arrays are not JSON");
33
+ snapshot.push(cloneJson(value[index], owner, `${path}[${index}]`, ancestors));
34
+ }
35
+ ancestors.delete(value);
36
+ return snapshot;
37
+ } else {
27
38
  assertPlainObject(value, owner, path);
28
39
  if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
29
- for (const [key, child] of Object.entries(value)) assertJson(child, owner, `${path}.${key}`, ancestors);
40
+ const snapshot = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child, owner, `${path}.${key}`, ancestors)]));
41
+ ancestors.delete(value);
42
+ return snapshot;
30
43
  }
31
- ancestors.delete(value);
32
44
  }
33
45
  function assertPlainObject(value, owner, path) {
34
- if (value === null || typeof value !== "object" || Array.isArray(value)) fail(owner, path, "expected a plain object");
46
+ if (Object(value) !== value || Array.isArray(value)) fail(owner, path, "expected a plain object");
35
47
  const prototype = Object.getPrototypeOf(value);
36
48
  if (prototype !== Object.prototype && prototype !== null) fail(owner, path, "expected a plain object");
37
49
  }
38
50
  function assertName(value, owner, path) {
39
- if (typeof value !== "string" || value.trim() === "") fail(owner, path, "expected a non-empty string");
51
+ if (!nameSchema.safeParse(value).success) fail(owner, path, "expected a non-empty string");
40
52
  }
41
53
  function assertOnlyKeys(value, allowed, owner) {
42
54
  for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(owner, `.${key}`, "unknown property");
43
55
  }
44
56
  function canonicalJson(value) {
45
57
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
46
- if (value !== null && typeof value === "object") {
47
- const object = value;
48
- return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
49
- }
58
+ if (isJsonObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
50
59
  return JSON.stringify(value);
51
60
  }
52
61
  function fail(owner, path, message) {
53
62
  throw new TypeError(`${owner}${path}: ${message}`);
54
63
  }
64
+ function isJsonContainer(value) {
65
+ return value !== null && Object(value) === value;
66
+ }
67
+ function isJsonObject(value) {
68
+ return isJsonContainer(value) && !Array.isArray(value);
69
+ }
55
70
  //#endregion
56
71
  //#region src/matrix.ts
72
+ const axisSchema = z.string();
57
73
  function expandMatrix(matrix, owner) {
58
74
  if (matrix === void 0) return [{}];
59
75
  assertPlainObject(matrix, owner, ".matrix");
60
76
  let cases = [{}];
61
- for (const [axis, values] of Object.entries(matrix)) {
77
+ for (const candidate of Reflect.ownKeys(matrix)) {
78
+ const parsed = axisSchema.safeParse(candidate);
79
+ if (!parsed.success || !Object.prototype.propertyIsEnumerable.call(matrix, candidate)) fail(owner, ".matrix", "axis names must be enumerable strings");
80
+ const axis = parsed.data;
62
81
  if (axis.trim() === "") fail(owner, ".matrix", "axis names must not be empty");
82
+ const index = Number(axis);
83
+ if (Number.isInteger(index) && index >= 0 && index < 2 ** 32 - 1 && String(index) === axis) fail(owner, `.matrix.${axis}`, "array-index axis names cannot preserve insertion order");
84
+ const values = matrix[axis];
63
85
  if (!Array.isArray(values)) fail(owner, `.matrix.${axis}`, "expected an array");
64
86
  if (values.length === 0) fail(owner, `.matrix.${axis}`, "expected at least one value");
65
- values.forEach((value, index) => {
66
- assertJson(value, owner, `.matrix.${axis}[${index}]`);
67
- });
68
- cases = cases.flatMap((coordinates) => values.map((value) => ({
87
+ const snapshots = values.map((value, index) => snapshotJson(value, owner, `.matrix.${axis}[${index}]`));
88
+ const axisValues = /* @__PURE__ */ new Set();
89
+ for (const [index, value] of snapshots.entries()) {
90
+ const key = canonicalJson(value);
91
+ if (axisValues.has(key)) fail(owner, `.matrix.${axis}[${index}]`, `duplicate axis value ${JSON.stringify(value)}`);
92
+ axisValues.add(key);
93
+ }
94
+ cases = cases.flatMap((coordinates) => snapshots.map((value) => ({
69
95
  ...coordinates,
70
96
  [axis]: value
71
97
  })));
72
98
  }
73
- const coordinates = /* @__PURE__ */ new Set();
74
- for (const value of cases) {
75
- const key = canonicalJson(value);
76
- if (coordinates.has(key)) fail(owner, ".matrix", `duplicate coordinate ${JSON.stringify(value)}`);
77
- coordinates.add(key);
78
- }
79
99
  return cases;
80
100
  }
81
101
  function caseName(matrix, index) {
@@ -109,8 +129,7 @@ function defineWorkflow(definition) {
109
129
  setup,
110
130
  vitest
111
131
  });
112
- assertJson(output, `Task ${JSON.stringify(prepared.task.name)}`, ".output");
113
- meta.invoker.output = output;
132
+ meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(prepared.task.name)}`, ".output");
114
133
  });
115
134
  }
116
135
  });
@@ -123,51 +142,49 @@ function prepareWorkflow(definition) {
123
142
  "metadata",
124
143
  "tasks"
125
144
  ], "Workflow");
126
- assertName(definition.name, "Workflow", ".name");
127
- const metadata = definition.metadata;
128
- if (metadata !== void 0) {
129
- assertJson(metadata, `Workflow ${JSON.stringify(definition.name)}`, ".metadata");
130
- assertPlainObject(metadata, `Workflow ${JSON.stringify(definition.name)}`, ".metadata");
131
- }
132
- if (!Array.isArray(definition.tasks) || definition.tasks.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
145
+ const { name, metadata: workflowMetadata, tasks: taskDefinitions } = definition;
146
+ assertName(name, "Workflow", ".name");
147
+ const metadata = workflowMetadata === void 0 ? void 0 : snapshotJson(workflowMetadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
148
+ if (metadata !== void 0) assertPlainObject(metadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
149
+ if (!Array.isArray(taskDefinitions) || taskDefinitions.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
133
150
  const names = /* @__PURE__ */ new Set();
134
- const tasks = definition.tasks.map((value, index) => {
135
- const owner = `Workflow ${JSON.stringify(definition.name)} Task ${index + 1}`;
136
- assertPlainObject(value, owner, "");
137
- const task = value;
138
- if (task[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
139
- assertOnlyKeys(task, [
140
- "name",
141
- "matrix",
142
- "setup",
143
- "run",
144
- "teardown"
145
- ], owner);
146
- assertName(task.name, owner, ".name");
147
- if (names.has(task.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(task.name)}`);
148
- names.add(task.name);
149
- if (typeof task.run !== "function") fail(owner, ".run", "expected a function");
150
- if (task.setup !== void 0 && typeof task.setup !== "function") fail(owner, ".setup", "expected a function");
151
- if (task.teardown !== void 0 && typeof task.teardown !== "function") fail(owner, ".teardown", "expected a function");
152
- if (task.teardown && !task.setup) fail(owner, ".teardown", "requires setup");
153
- const cases = expandMatrix(task.matrix, `Task ${JSON.stringify(task.name)}`);
154
- return {
155
- task,
156
- cases,
157
- names: cases.map(caseName),
158
- metadata: cases.map((matrix) => metadata === void 0 ? {
159
- schema: 1,
160
- matrix
161
- } : {
162
- schema: 1,
163
- matrix,
164
- metadata
165
- })
166
- };
167
- });
168
151
  return {
169
- name: definition.name,
170
- tasks
152
+ name,
153
+ tasks: taskDefinitions.map((value, index) => {
154
+ const owner = `Workflow ${JSON.stringify(name)} Task ${index + 1}`;
155
+ assertPlainObject(value, owner, "");
156
+ if (value[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
157
+ const task = value;
158
+ assertOnlyKeys(task, [
159
+ "name",
160
+ "matrix",
161
+ "setup",
162
+ "run",
163
+ "teardown"
164
+ ], owner);
165
+ const taskSnapshot = { ...task };
166
+ assertName(taskSnapshot.name, owner, ".name");
167
+ if (names.has(taskSnapshot.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(taskSnapshot.name)}`);
168
+ names.add(taskSnapshot.name);
169
+ if (!z.function().safeParse(taskSnapshot.run).success) fail(owner, ".run", "expected a function");
170
+ if (taskSnapshot.setup !== void 0 && !z.function().safeParse(taskSnapshot.setup).success) fail(owner, ".setup", "expected a function");
171
+ if (taskSnapshot.teardown !== void 0 && !z.function().safeParse(taskSnapshot.teardown).success) fail(owner, ".teardown", "expected a function");
172
+ if (taskSnapshot.teardown && !taskSnapshot.setup) fail(owner, ".teardown", "requires setup");
173
+ const cases = expandMatrix(taskSnapshot.matrix, `Task ${JSON.stringify(taskSnapshot.name)}`);
174
+ return {
175
+ task: taskSnapshot,
176
+ cases,
177
+ names: cases.map(caseName),
178
+ metadata: cases.map((matrix) => metadata === void 0 ? {
179
+ schema: 1,
180
+ matrix
181
+ } : {
182
+ schema: 1,
183
+ matrix,
184
+ metadata
185
+ })
186
+ };
187
+ })
171
188
  };
172
189
  }
173
190
  //#endregion
@@ -0,0 +1,10 @@
1
+ import { Reporter } from "vitest/reporters";
2
+ //#region src/slack/reporter.d.ts
3
+ type SlackReporterOptions = {
4
+ readonly token: string;
5
+ readonly channel: string;
6
+ readonly runUrl?: string;
7
+ };
8
+ declare function slackReporter(options: SlackReporterOptions): Reporter;
9
+ //#endregion
10
+ export { type SlackReporterOptions, slackReporter };
package/dist/slack.mjs ADDED
@@ -0,0 +1,425 @@
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
+ };
53
+ workflow.tasks.set(taskSuite, task);
54
+ }
55
+ task.total += 1;
56
+ const result = testCase.result();
57
+ const diagnostic = testCase.diagnostic();
58
+ task[result.state === "pending" ? "incomplete" : result.state] += 1;
59
+ if ((diagnostic?.retryCount ?? 0) > 0) task.retried += 1;
60
+ if (diagnostic) {
61
+ task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
62
+ task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
63
+ }
64
+ if (result.state === "failed") task.failures.push({
65
+ task: task.name,
66
+ caseName: testCase.name,
67
+ matrix: invoker.matrix,
68
+ messages: result.errors.map(errorMessage)
69
+ });
70
+ }
71
+ return [...workflows.values()].map((workflow) => {
72
+ const failures = [];
73
+ addErrors(failures, workflow.suite.errors());
74
+ addErrors(failures, workflow.module.errors());
75
+ const collectedTasks = [...workflow.tasks.values()];
76
+ for (const task of collectedTasks) {
77
+ failures.push(...task.failures);
78
+ addErrors(failures, task.suite.errors(), task.name);
79
+ }
80
+ const { startedAt, endedAt } = timeSpan(collectedTasks);
81
+ return {
82
+ name: workflow.name,
83
+ metadata: workflow.metadata,
84
+ tasks: collectedTasks.map(({ suite: _suite, failures: _failures, startedAt, endedAt, ...task }) => ({
85
+ ...task,
86
+ duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
87
+ })),
88
+ failures: deduplicateFailures(failures),
89
+ startedAt,
90
+ endedAt
91
+ };
92
+ });
93
+ }
94
+ function summaryMessages(reports, runUrl) {
95
+ const timestamp = Math.floor(Date.now() / 1e3);
96
+ const { startedAt, endedAt } = timeSpan(reports);
97
+ const footer = [
98
+ `Elapsed: ${formatDuration(startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt)}`,
99
+ `<!date^${timestamp}^{date_short_pretty} at {time}|${(/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString()}>`,
100
+ ...runUrl ? [`<${escapeSlackControl(runUrl)}|View run>`] : []
101
+ ].join(" • ");
102
+ return reports.flatMap(workflowAttachments).map((attachment, index) => ({
103
+ text: index === 0 ? "Invoker Report" : "Invoker Report (continued)",
104
+ attachments: [attachment, ...index === 0 ? [{ blocks: [{
105
+ type: "context",
106
+ elements: [{
107
+ type: "mrkdwn",
108
+ text: footer
109
+ }]
110
+ }] }] : []]
111
+ }));
112
+ }
113
+ function workflowAttachments(report) {
114
+ const tables = taskTables(report.tasks);
115
+ return tables.map((rows, index) => workflowAttachment(report, rows, index, tables.length));
116
+ }
117
+ function workflowAttachment(report, rows, index, pages) {
118
+ const totals = taskTotals(report.tasks);
119
+ const status = workflowStatus(report);
120
+ const metadata = metadataText(report.metadata);
121
+ const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
122
+ const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${totals.passed}/${totals.total} passed${page}*`;
123
+ return {
124
+ color: status.color,
125
+ blocks: [
126
+ {
127
+ type: "section",
128
+ text: {
129
+ type: "mrkdwn",
130
+ text: headline
131
+ }
132
+ },
133
+ ...metadata ? [{
134
+ type: "context",
135
+ elements: [{
136
+ type: "mrkdwn",
137
+ text: metadata
138
+ }]
139
+ }] : [],
140
+ {
141
+ type: "table",
142
+ column_settings: [
143
+ { is_wrapped: true },
144
+ { align: "right" },
145
+ { align: "right" },
146
+ { align: "right" },
147
+ { align: "right" },
148
+ { align: "right" }
149
+ ],
150
+ rows
151
+ }
152
+ ]
153
+ };
154
+ }
155
+ function failureMessages(report) {
156
+ return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
157
+ const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
158
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
159
+ const metadata = metadataText(report.metadata);
160
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
161
+ return chunk(failures.map((failure) => {
162
+ 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("")}`;
163
+ }), 3e3).map((details, index, messages) => {
164
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
165
+ return {
166
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
167
+ attachments: [{
168
+ color: "danger",
169
+ blocks: [
170
+ {
171
+ type: "section",
172
+ text: {
173
+ type: "mrkdwn",
174
+ text: `🔴 *${title} — ${failures.length} failed${part}*`
175
+ }
176
+ },
177
+ {
178
+ type: "context",
179
+ elements: [{
180
+ type: "mrkdwn",
181
+ text: context
182
+ }]
183
+ },
184
+ {
185
+ type: "section",
186
+ text: {
187
+ type: "mrkdwn",
188
+ text: details
189
+ }
190
+ }
191
+ ]
192
+ }]
193
+ };
194
+ });
195
+ });
196
+ }
197
+ function unhandledErrorMessages(errors) {
198
+ const messages = [...new Set(errors.map(errorMessage))];
199
+ return chunk(messages.map((message) => `• ${escapeSlack(message)}`), SECTION_CHARACTER_LIMIT).map((details, index, chunks) => {
200
+ const part = chunks.length > 1 ? ` (${index + 1}/${chunks.length})` : "";
201
+ return {
202
+ text: `Invoker run — ${messages.length} unhandled error${messages.length === 1 ? "" : "s"}${part}`,
203
+ attachments: [{
204
+ color: "danger",
205
+ blocks: [{
206
+ type: "section",
207
+ text: {
208
+ type: "mrkdwn",
209
+ text: `🔴 *Run errors${part}*`
210
+ }
211
+ }, {
212
+ type: "section",
213
+ text: {
214
+ type: "mrkdwn",
215
+ text: details
216
+ }
217
+ }]
218
+ }]
219
+ };
220
+ });
221
+ }
222
+ function addErrors(failures, errors, task) {
223
+ const messages = errors.map(errorMessage);
224
+ if (messages.length > 0) failures.push({
225
+ task,
226
+ messages
227
+ });
228
+ }
229
+ function errorMessage(cause) {
230
+ const message = errorMessageSchema.safeParse(cause);
231
+ if (message.success) return message.data.message;
232
+ const stack = errorStackSchema.safeParse(cause);
233
+ if (stack.success) return stack.data.stack.split("\n", 1)[0];
234
+ return String(cause);
235
+ }
236
+ function deduplicateFailures(failures) {
237
+ const seen = /* @__PURE__ */ new Set();
238
+ return failures.flatMap((failure) => {
239
+ const messages = failure.messages.filter((message) => {
240
+ const key = JSON.stringify([
241
+ failure.task,
242
+ failure.caseName,
243
+ failure.matrix,
244
+ message
245
+ ]);
246
+ if (seen.has(key)) return false;
247
+ seen.add(key);
248
+ return true;
249
+ });
250
+ return messages.length > 0 ? [{
251
+ ...failure,
252
+ messages
253
+ }] : [];
254
+ });
255
+ }
256
+ function escapeSlack(value) {
257
+ return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
258
+ }
259
+ function escapeSlackControl(value) {
260
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
261
+ }
262
+ function taskTotals(tasks) {
263
+ return tasks.reduce((totals, task) => ({
264
+ total: totals.total + task.total,
265
+ passed: totals.passed + task.passed
266
+ }), {
267
+ total: 0,
268
+ passed: 0
269
+ });
270
+ }
271
+ function workflowStatus(report) {
272
+ if (report.failures.length > 0 || report.tasks.some((task) => task.failed > 0)) return {
273
+ emoji: "🔴",
274
+ color: "danger"
275
+ };
276
+ if (report.tasks.some((task) => task.retried > 0 || task.skipped > 0 || task.incomplete > 0)) return {
277
+ emoji: "🟡",
278
+ color: "warning"
279
+ };
280
+ return {
281
+ emoji: "🟢",
282
+ color: "good"
283
+ };
284
+ }
285
+ function metadataText(metadata) {
286
+ if (!metadata || Object.keys(metadata).length === 0) return void 0;
287
+ return truncate(Object.entries(metadata).map(([key, value]) => {
288
+ const string = stringSchema.safeParse(value);
289
+ return `*${escapeSlack(key)}:* ${escapeSlack(string.success ? string.data : JSON.stringify(value))}`;
290
+ }).join(" • "), METADATA_CHARACTER_LIMIT);
291
+ }
292
+ function rawCell(text) {
293
+ return {
294
+ type: "raw_text",
295
+ text
296
+ };
297
+ }
298
+ function taskTables(tasks) {
299
+ const header = [
300
+ "Task",
301
+ "Passed",
302
+ "Retries",
303
+ "Failed",
304
+ "Skipped",
305
+ "Time"
306
+ ].map(rawCell);
307
+ const headerCharacters = rowCharacters(header);
308
+ const tables = [];
309
+ let rows = [header];
310
+ let characters = headerCharacters;
311
+ for (const task of tasks) {
312
+ const values = [
313
+ String(task.passed),
314
+ String(task.retried),
315
+ String(task.failed),
316
+ String(task.skipped),
317
+ formatDuration(task.duration)
318
+ ];
319
+ const taskNameLimit = TABLE_CHARACTER_LIMIT - headerCharacters - values.reduce((total, value) => total + value.length, 0);
320
+ const row = [truncate(task.name, taskNameLimit), ...values].map(rawCell);
321
+ const rowLength = rowCharacters(row);
322
+ if (rows.length === TABLE_ROW_LIMIT || characters + rowLength > TABLE_CHARACTER_LIMIT) {
323
+ tables.push(rows);
324
+ rows = [header];
325
+ characters = headerCharacters;
326
+ }
327
+ rows.push(row);
328
+ characters += rowLength;
329
+ }
330
+ tables.push(rows);
331
+ return tables;
332
+ }
333
+ function rowCharacters(row) {
334
+ return row.reduce((total, cell) => total + cell.text.length, 0);
335
+ }
336
+ function formatDuration(milliseconds) {
337
+ if (milliseconds < 1e3) return `${Math.round(milliseconds)}ms`;
338
+ return `${(milliseconds / 1e3).toFixed(1)}s`;
339
+ }
340
+ function chunk(entries, limit) {
341
+ const chunks = [];
342
+ for (const entry of entries) {
343
+ const value = entry.length > limit ? `${entry.slice(0, limit - 1)}…` : entry;
344
+ const previous = chunks.at(-1);
345
+ if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
346
+ else chunks.push(value);
347
+ }
348
+ return chunks;
349
+ }
350
+ function truncate(value, limit) {
351
+ return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
352
+ }
353
+ function timeSpan(values) {
354
+ let startedAt;
355
+ let endedAt;
356
+ for (const value of values) {
357
+ if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
358
+ if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
359
+ }
360
+ return {
361
+ startedAt,
362
+ endedAt
363
+ };
364
+ }
365
+ //#endregion
366
+ //#region src/slack/reporter.ts
367
+ function slackReporter(options) {
368
+ const client = new WebClient(options.token, {
369
+ rejectRateLimitedCalls: true,
370
+ retryConfig: { retries: 0 },
371
+ timeout: 1e4
372
+ });
373
+ const postMessage = async (arguments_) => {
374
+ try {
375
+ return await client.chat.postMessage(arguments_);
376
+ } catch (error) {
377
+ if (!(error instanceof WebAPIRateLimitedError)) throw error;
378
+ await setTimeout(error.retryAfter * 1e3);
379
+ return client.chat.postMessage(arguments_);
380
+ }
381
+ };
382
+ return { async onTestRunEnd(modules, unhandledErrors) {
383
+ const reports = collectWorkflowReports(modules);
384
+ if (reports.length === 0) return;
385
+ const [parentMessage, ...continuations] = summaryMessages(reports, options.runUrl);
386
+ if (!parentMessage) return;
387
+ let parentTimestamp;
388
+ try {
389
+ const parentArguments = {
390
+ channel: options.channel,
391
+ ...parentMessage,
392
+ mrkdwn: false,
393
+ unfurl_links: false
394
+ };
395
+ parentTimestamp = (await postMessage(parentArguments)).ts;
396
+ } catch {
397
+ console.warn("[invoker] Could not post the Slack report.");
398
+ return;
399
+ }
400
+ const replies = [
401
+ ...continuations,
402
+ ...reports.flatMap(failureMessages),
403
+ ...unhandledErrorMessages(unhandledErrors)
404
+ ];
405
+ if (replies.length > 0 && !parentTimestamp) {
406
+ console.warn("[invoker] Slack did not return a timestamp for the report thread.");
407
+ return;
408
+ }
409
+ let failedMessages = 0;
410
+ for (const reply of replies) try {
411
+ await postMessage({
412
+ channel: options.channel,
413
+ ...reply,
414
+ thread_ts: parentTimestamp,
415
+ mrkdwn: false,
416
+ unfurl_links: false
417
+ });
418
+ } catch {
419
+ failedMessages += 1;
420
+ }
421
+ if (failedMessages > 0) console.warn(`[invoker] Could not post ${failedMessages} Slack report message${failedMessages === 1 ? "" : "s"}.`);
422
+ } };
423
+ }
424
+ //#endregion
425
+ 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.2.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
  },