@akshatmittal/invoker 0.2.0 → 0.3.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
@@ -104,10 +104,10 @@ import { defineTask } from "@akshatmittal/invoker";
104
104
 
105
105
  export const evaluateModels = defineTask({
106
106
  name: "evaluate-models",
107
- matrix: {
107
+ matrix: async () => ({
108
108
  model: ["gpt-5", "gpt-5-mini"],
109
109
  dataset: ["support", "sales"],
110
- },
110
+ }),
111
111
  setup: async ({ cases }) => loadFixtures(cases),
112
112
  run: async ({ matrix, setup, vitest }) => {
113
113
  vitest.expect(setup.has(matrix.dataset)).toBe(true);
@@ -139,7 +139,8 @@ defineWorkflow({
139
139
  });
140
140
  ```
141
141
 
142
- Matrix literals determine the exact `matrix` type, `setup` determines the exact
142
+ The Matrix function runs during collection and its returned literal determines the exact
143
+ `matrix` type. `setup` determines the exact
143
144
  shared setup type, and the exact JSON return type is retained on the Task.
144
145
  Axis names must be non-empty, enumerable strings that are not array indexes.
145
146
  Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
@@ -195,16 +196,14 @@ retries.
195
196
  ## Notify Slack
196
197
 
197
198
  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.
199
+ Vitest run containing every Workflow card. Each card includes aggregate results,
200
+ Workflow metadata, and a table of Task counts and durations. A shared footer
201
+ contains the elapsed span from the first Case start to the final Case completion,
202
+ a localized timestamp, and the optional run link. Final failures, successful
203
+ retry details, skipped Case reasons, and unhandled run errors are posted in the
204
+ same thread. Delivery failures are isolated to the affected reply. Ambiguous
205
+ transport failures are not retried; an explicit Slack rate-limit rejection is
206
+ reattempted only after its required delay.
208
207
 
209
208
  ```ts
210
209
  import { slackReporter } from "@akshatmittal/invoker/slack";
package/dist/index.d.mts CHANGED
@@ -34,7 +34,7 @@ type TeardownContext<M extends Matrix, Setup> = SetupContext<M> & {
34
34
  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
- readonly matrix: M;
37
+ readonly matrix: () => Promise<M>;
38
38
  readonly [taskDefinitionBrand]: true;
39
39
  readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
40
40
  readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
@@ -42,14 +42,14 @@ interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix
42
42
  }
43
43
  type TaskWithSetup<Name extends string, M extends Matrix, Setup, Output extends JsonValue> = {
44
44
  readonly name: Name;
45
- readonly matrix?: M;
45
+ readonly matrix?: () => Promise<M>;
46
46
  readonly setup: (context: SetupContext<M>) => Awaitable<Setup>;
47
47
  readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
48
48
  readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
49
49
  };
50
50
  type TaskWithoutSetup<Name extends string, M extends Matrix, Output extends JsonValue> = {
51
51
  readonly name: Name;
52
- readonly matrix?: M;
52
+ readonly matrix?: () => Promise<M>;
53
53
  readonly setup?: never;
54
54
  readonly run: (context: TaskContext<CaseCoordinates<M>, undefined>) => Awaitable<Output>;
55
55
  readonly teardown?: never;
@@ -58,7 +58,7 @@ declare function defineTask<const Name extends string, const M extends Matrix =
58
58
  declare function defineTask<const Name extends string, const M extends Matrix = Record<never, never>, const Output extends JsonValue = JsonValue>(definition: TaskWithoutSetup<Name, M, Output>): TaskDefinition<Name, M, undefined, Output>;
59
59
  type AnyTaskDefinition = {
60
60
  readonly name: string;
61
- readonly matrix: Matrix;
61
+ readonly matrix: () => Promise<Matrix>;
62
62
  readonly [taskDefinitionBrand]: true;
63
63
  };
64
64
  //#endregion
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ const taskDefinitionBrand = Symbol("invoker.task");
5
5
  function defineTask(definition) {
6
6
  return {
7
7
  ...definition,
8
- matrix: definition.matrix ?? {},
8
+ matrix: definition.matrix ?? (async () => ({})),
9
9
  [taskDefinitionBrand]: true
10
10
  };
11
11
  }
@@ -71,7 +71,6 @@ function isJsonObject(value) {
71
71
  //#region src/matrix.ts
72
72
  const axisSchema = z.string();
73
73
  function expandMatrix(matrix, owner) {
74
- if (matrix === void 0) return [{}];
75
74
  assertPlainObject(matrix, owner, ".matrix");
76
75
  let cases = [{}];
77
76
  for (const candidate of Reflect.ownKeys(matrix)) {
@@ -105,9 +104,9 @@ function caseName(matrix, index) {
105
104
  //#endregion
106
105
  //#region src/workflow.ts
107
106
  function defineWorkflow(definition) {
108
- const workflow = prepareWorkflow(definition);
109
- describe(workflow.name, { concurrent: false }, () => {
110
- for (const prepared of workflow.tasks) describe(prepared.task.name, { concurrent: false }, () => {
107
+ describe(definition.name, { concurrent: false }, async () => {
108
+ const tasks = await prepareWorkflow(definition);
109
+ for (const prepared of tasks) describe(prepared.task.name, { concurrent: false }, () => {
111
110
  let setup;
112
111
  const setupTask = prepared.task.setup;
113
112
  if (setupTask) beforeAll(async () => {
@@ -135,7 +134,7 @@ function defineWorkflow(definition) {
135
134
  });
136
135
  });
137
136
  }
138
- function prepareWorkflow(definition) {
137
+ async function prepareWorkflow(definition) {
139
138
  assertPlainObject(definition, "Workflow", "");
140
139
  assertOnlyKeys(definition, [
141
140
  "name",
@@ -148,44 +147,45 @@ function prepareWorkflow(definition) {
148
147
  if (metadata !== void 0) assertPlainObject(metadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
149
148
  if (!Array.isArray(taskDefinitions) || taskDefinitions.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
150
149
  const names = /* @__PURE__ */ new Set();
151
- return {
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
- })
188
- };
150
+ const tasks = taskDefinitions.map((value, index) => {
151
+ const owner = `Workflow ${JSON.stringify(name)} Task ${index + 1}`;
152
+ assertPlainObject(value, owner, "");
153
+ if (value[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
154
+ const task = value;
155
+ assertOnlyKeys(task, [
156
+ "name",
157
+ "matrix",
158
+ "setup",
159
+ "run",
160
+ "teardown"
161
+ ], owner);
162
+ const taskSnapshot = { ...task };
163
+ assertName(taskSnapshot.name, owner, ".name");
164
+ if (names.has(taskSnapshot.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(taskSnapshot.name)}`);
165
+ names.add(taskSnapshot.name);
166
+ if (!z.function().safeParse(taskSnapshot.run).success) fail(owner, ".run", "expected a function");
167
+ if (!z.function().safeParse(taskSnapshot.matrix).success) fail(owner, ".matrix", "expected a function");
168
+ if (taskSnapshot.setup !== void 0 && !z.function().safeParse(taskSnapshot.setup).success) fail(owner, ".setup", "expected a function");
169
+ if (taskSnapshot.teardown !== void 0 && !z.function().safeParse(taskSnapshot.teardown).success) fail(owner, ".teardown", "expected a function");
170
+ if (taskSnapshot.teardown && !taskSnapshot.setup) fail(owner, ".teardown", "requires setup");
171
+ return taskSnapshot;
172
+ });
173
+ return await Promise.all(tasks.map(async (task) => {
174
+ const cases = expandMatrix(await task.matrix(), `Task ${JSON.stringify(task.name)}`);
175
+ return {
176
+ task,
177
+ cases,
178
+ names: cases.map(caseName),
179
+ metadata: cases.map((matrix) => metadata === void 0 ? {
180
+ schema: 1,
181
+ matrix
182
+ } : {
183
+ schema: 1,
184
+ matrix,
185
+ metadata
186
+ })
187
+ };
188
+ }));
189
189
  }
190
190
  //#endregion
191
191
  export { defineTask, defineWorkflow };
package/dist/slack.mjs CHANGED
@@ -48,7 +48,9 @@ function collectWorkflowReports(modules) {
48
48
  failed: 0,
49
49
  skipped: 0,
50
50
  incomplete: 0,
51
- failures: []
51
+ failures: [],
52
+ retries: [],
53
+ skips: []
52
54
  };
53
55
  workflow.tasks.set(taskSuite, task);
54
56
  }
@@ -56,7 +58,17 @@ function collectWorkflowReports(modules) {
56
58
  const result = testCase.result();
57
59
  const diagnostic = testCase.diagnostic();
58
60
  task[result.state === "pending" ? "incomplete" : result.state] += 1;
59
- if ((diagnostic?.retryCount ?? 0) > 0) task.retried += 1;
61
+ const retryCount = diagnostic?.retryCount ?? 0;
62
+ if (retryCount > 0) {
63
+ task.retried += 1;
64
+ if (result.state === "passed") task.retries.push({
65
+ task: task.name,
66
+ caseName: testCase.name,
67
+ matrix: invoker.matrix,
68
+ count: retryCount,
69
+ messages: (result.errors ?? []).map(errorMessage)
70
+ });
71
+ }
60
72
  if (diagnostic) {
61
73
  task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
62
74
  task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
@@ -67,31 +79,43 @@ function collectWorkflowReports(modules) {
67
79
  matrix: invoker.matrix,
68
80
  messages: result.errors.map(errorMessage)
69
81
  });
82
+ else if (result.state === "skipped") task.skips.push({
83
+ task: task.name,
84
+ caseName: testCase.name,
85
+ matrix: invoker.matrix,
86
+ reason: result.note || "No reason provided"
87
+ });
70
88
  }
71
89
  return [...workflows.values()].map((workflow) => {
72
90
  const failures = [];
91
+ const retries = [];
92
+ const skips = [];
73
93
  addErrors(failures, workflow.suite.errors());
74
94
  addErrors(failures, workflow.module.errors());
75
95
  const collectedTasks = [...workflow.tasks.values()];
76
96
  for (const task of collectedTasks) {
77
97
  failures.push(...task.failures);
98
+ retries.push(...task.retries);
99
+ skips.push(...task.skips);
78
100
  addErrors(failures, task.suite.errors(), task.name);
79
101
  }
80
102
  const { startedAt, endedAt } = timeSpan(collectedTasks);
81
103
  return {
82
104
  name: workflow.name,
83
105
  metadata: workflow.metadata,
84
- tasks: collectedTasks.map(({ suite: _suite, failures: _failures, startedAt, endedAt, ...task }) => ({
106
+ tasks: collectedTasks.map(({ suite: _suite, failures: _failures, retries: _retries, skips: _skips, startedAt, endedAt, ...task }) => ({
85
107
  ...task,
86
108
  duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
87
109
  })),
88
110
  failures: deduplicateFailures(failures),
111
+ retries,
112
+ skips,
89
113
  startedAt,
90
114
  endedAt
91
115
  };
92
116
  });
93
117
  }
94
- function summaryMessages(reports, runUrl) {
118
+ function summaryMessage(reports, runUrl) {
95
119
  const timestamp = Math.floor(Date.now() / 1e3);
96
120
  const { startedAt, endedAt } = timeSpan(reports);
97
121
  const footer = [
@@ -99,16 +123,16 @@ function summaryMessages(reports, runUrl) {
99
123
  `<!date^${timestamp}^{date_short_pretty} at {time}|${(/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString()}>`,
100
124
  ...runUrl ? [`<${escapeSlackControl(runUrl)}|View run>`] : []
101
125
  ].join(" • ");
102
- return reports.flatMap(workflowAttachments).map((attachment, index) => ({
103
- text: index === 0 ? "Invoker Report" : "Invoker Report (continued)",
104
- attachments: [attachment, ...index === 0 ? [{ blocks: [{
126
+ return {
127
+ text: "Invoker Report",
128
+ attachments: [...reports.flatMap(workflowAttachments), { blocks: [{
105
129
  type: "context",
106
130
  elements: [{
107
131
  type: "mrkdwn",
108
132
  text: footer
109
133
  }]
110
- }] }] : []]
111
- }));
134
+ }] }]
135
+ };
112
136
  }
113
137
  function workflowAttachments(report) {
114
138
  const tables = taskTables(report.tasks);
@@ -194,6 +218,93 @@ function failureMessages(report) {
194
218
  });
195
219
  });
196
220
  }
221
+ function retryMessages(report) {
222
+ return [...Map.groupBy(report.retries, (retry) => retry.task)].flatMap(([task, retries]) => {
223
+ const title = escapeSlack(truncate(task, NAME_CHARACTER_LIMIT));
224
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
225
+ const metadata = metadataText(report.metadata);
226
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
227
+ return chunk(retries.map((retry) => {
228
+ const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
229
+ const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
230
+ return `*${escapeSlack(retry.caseName)}*\nRetries: ${retry.count}${matrix}${messages}`;
231
+ }), SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
232
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
233
+ return {
234
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task, NAME_CHARACTER_LIMIT)} — ${retries.length} retried${part}`,
235
+ attachments: [{
236
+ color: "warning",
237
+ blocks: [
238
+ {
239
+ type: "section",
240
+ text: {
241
+ type: "mrkdwn",
242
+ text: `🟡 *${title} — ${retries.length} retried${part}*`
243
+ }
244
+ },
245
+ {
246
+ type: "context",
247
+ elements: [{
248
+ type: "mrkdwn",
249
+ text: context
250
+ }]
251
+ },
252
+ {
253
+ type: "section",
254
+ text: {
255
+ type: "mrkdwn",
256
+ text: details
257
+ }
258
+ }
259
+ ]
260
+ }]
261
+ };
262
+ });
263
+ });
264
+ }
265
+ function skipMessages(report) {
266
+ return [...Map.groupBy(report.skips, (skip) => skip.task)].flatMap(([task, skips]) => {
267
+ const title = escapeSlack(truncate(task, NAME_CHARACTER_LIMIT));
268
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
269
+ const metadata = metadataText(report.metadata);
270
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
271
+ return chunk(skips.map((skip) => {
272
+ const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(skip.matrix))}`;
273
+ return `*${escapeSlack(skip.caseName)}*${matrix}\nReason: ${escapeSlack(skip.reason)}`;
274
+ }), SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
275
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
276
+ return {
277
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task, NAME_CHARACTER_LIMIT)} — ${skips.length} skipped${part}`,
278
+ attachments: [{
279
+ color: "warning",
280
+ blocks: [
281
+ {
282
+ type: "section",
283
+ text: {
284
+ type: "mrkdwn",
285
+ text: `🟡 *${title} — ${skips.length} skipped${part}*`
286
+ }
287
+ },
288
+ {
289
+ type: "context",
290
+ elements: [{
291
+ type: "mrkdwn",
292
+ text: context
293
+ }]
294
+ },
295
+ {
296
+ type: "section",
297
+ text: {
298
+ type: "mrkdwn",
299
+ text: details
300
+ }
301
+ }
302
+ ]
303
+ }]
304
+ };
305
+ });
306
+ });
307
+ }
197
308
  function unhandledErrorMessages(errors) {
198
309
  const messages = [...new Set(errors.map(errorMessage))];
199
310
  return chunk(messages.map((message) => `• ${escapeSlack(message)}`), SECTION_CHARACTER_LIMIT).map((details, index, chunks) => {
@@ -382,8 +493,7 @@ function slackReporter(options) {
382
493
  return { async onTestRunEnd(modules, unhandledErrors) {
383
494
  const reports = collectWorkflowReports(modules);
384
495
  if (reports.length === 0) return;
385
- const [parentMessage, ...continuations] = summaryMessages(reports, options.runUrl);
386
- if (!parentMessage) return;
496
+ const parentMessage = summaryMessage(reports, options.runUrl);
387
497
  let parentTimestamp;
388
498
  try {
389
499
  const parentArguments = {
@@ -398,8 +508,9 @@ function slackReporter(options) {
398
508
  return;
399
509
  }
400
510
  const replies = [
401
- ...continuations,
402
511
  ...reports.flatMap(failureMessages),
512
+ ...reports.flatMap(retryMessages),
513
+ ...reports.flatMap(skipMessages),
403
514
  ...unhandledErrorMessages(unhandledErrors)
404
515
  ];
405
516
  if (replies.length > 0 && !parentTimestamp) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshatmittal/invoker",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Typed matrix-driven regression workflows for Vitest.",
5
5
  "keywords": [
6
6
  "matrix",