@akshatmittal/invoker 0.3.1 → 0.4.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
@@ -98,23 +98,29 @@ scheduler intentionally has no HTTP health endpoint.
98
98
 
99
99
  ## Define a Workflow
100
100
 
101
+ Declare a Task's required parameters once, then bind them from each Workflow.
102
+ TypeScript checks the bindings while inferring the Task's Matrix, setup, and Output:
103
+
101
104
  ```ts
102
105
  // regressions/model/tasks/evaluate-models.ts
103
106
  import { defineTask } from "@akshatmittal/invoker";
104
107
 
105
- export const evaluateModels = defineTask({
108
+ type EvaluateParams = {
109
+ environment: "staging" | "production";
110
+ baseline: string;
111
+ };
112
+
113
+ export const evaluateModels = defineTask<EvaluateParams>()({
106
114
  name: "evaluate-models",
107
- matrix: async () => ({
108
- model: ["gpt-5", "gpt-5-mini"],
115
+ matrix: async ({ params }) => ({
116
+ model: await discoverModels(params.environment),
109
117
  dataset: ["support", "sales"],
110
118
  }),
111
- setup: async ({ cases }) => loadFixtures(cases),
112
- run: async ({ matrix, setup, vitest }) => {
119
+ setup: async ({ params, cases }) => loadFixtures(params.environment, cases),
120
+ run: async ({ params, matrix, setup, vitest }) => {
113
121
  vitest.expect(setup.has(matrix.dataset)).toBe(true);
114
-
115
122
  return {
116
- model: matrix.model,
117
- dataset: matrix.dataset,
123
+ baseline: params.baseline,
118
124
  score: await evaluate(matrix, setup),
119
125
  };
120
126
  },
@@ -131,21 +137,51 @@ import { evaluateModels } from "../tasks/evaluate-models.js";
131
137
 
132
138
  defineWorkflow({
133
139
  name: "model-regressions",
134
- metadata: {
135
- commit: process.env.GITHUB_SHA ?? "local",
136
- baseline: "2026-08-01",
137
- },
138
- tasks: [evaluateModels],
140
+ metadata: { commit: process.env.GITHUB_SHA ?? "local" },
141
+ matrix: async () => ({ environment: ["staging", "production"] }),
142
+ tasks: ({ matrix }) => [
143
+ evaluateModels({
144
+ environment: matrix.environment,
145
+ baseline: "2026-09-01",
146
+ }),
147
+ ],
139
148
  });
140
149
  ```
141
150
 
142
- The Matrix function runs during collection and its returned literal determines the exact
143
- `matrix` type. `setup` determines the exact
144
- shared setup type, and the exact JSON return type is retained on the Task.
145
- Axis names must be non-empty, enumerable strings that are not array indexes.
146
- Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
147
- within that Task run concurrently, and teardown runs once after successful
148
- setup. Tasks run sequentially in their Workflow.
151
+ Calling `evaluateModels(...)` binds inputs; it does not execute the Task. All
152
+ parameter properties are required and JSON-compatible. Missing fields, optional
153
+ parameter declarations, and incompatible bindings fail typechecking. There is no
154
+ SDK defaults mechanism. Use setup for clients and other non-JSON resources.
155
+
156
+ For a parameterless Task, use `defineTask()({ name, run })` and bind it with no
157
+ argument. Workflow `tasks` is always a synchronous callback returning a nonempty
158
+ Task list. It can select different Tasks per Workflow coordinate; names must be
159
+ unique within each coordinate. Reuse the same Task in other Workflows with new
160
+ bindings.
161
+
162
+ Both Matrix functions run during collection. The Workflow Matrix resolves once;
163
+ Task binding runs once per Workflow coordinate, and each bound Task discovers its
164
+ own Matrix once. Task discovery can run concurrently across coordinates. All
165
+ bindings and matrices are validated before executable suites are registered; a
166
+ discovery failure prevents that Workflow's execution.
167
+
168
+ Workflow axes multiply Task axes. For two environments and three Task models,
169
+ there are six Cases. Execution follows the complete Task sequence for staging,
170
+ then the complete sequence for production. Each bound Task gets its own setup and
171
+ teardown, and its Cases run concurrently under Vitest's limits. Execution failures
172
+ normally allow later Tasks to continue; Vitest's bail configuration controls
173
+ stopping early.
174
+
175
+ Parameters are validated and snapshotted during collection and passed readonly to
176
+ Matrix discovery, setup, run, and teardown. Task `matrix` and setup's `cases`
177
+ contain only Task coordinates; Workflow coordinates enter through explicit
178
+ parameter binding. Retries share that binding's parameter snapshot and setup
179
+ result. Teardown runs once after successful setup.
180
+
181
+ Omitting either Matrix creates one coordinate, `{}`. Axis names must be non-empty,
182
+ enumerable strings that are not array indexes. Empty axes and duplicate values
183
+ are errors. Vitest shows Workflow → Workflow coordinate → Task → Case, with `[1]`
184
+ for empty coordinates and numbered axis values otherwise.
149
185
 
150
186
  ## Configure Vitest
151
187
 
@@ -178,26 +214,37 @@ Define additional Workflows in separate `*.test.ts` files. Vitest discovers
178
214
  them automatically; Invoker does not scan directories or require a central
179
215
  index.
180
216
 
181
- The metadata envelope is stable and JSON-compatible:
217
+ Schema 2 keeps both coordinate scopes and all bound parameters in the JSON report:
182
218
 
183
219
  ```json
184
220
  {
185
- "schema": 1,
186
- "matrix": { "model": "gpt-5", "dataset": "support" },
221
+ "schema": 2,
222
+ "matrix": {
223
+ "workflow": { "environment": "staging" },
224
+ "task": { "model": "model-a", "dataset": "support" }
225
+ },
226
+ "params": { "environment": "staging", "baseline": "2026-09-01" },
187
227
  "metadata": { "commit": "abc123" },
188
- "output": { "model": "gpt-5", "dataset": "support", "score": 0.92 }
228
+ "output": { "baseline": "2026-09-01", "score": 0.92 }
189
229
  }
190
230
  ```
191
231
 
192
- `output` is present only after a successful, JSON-valid Task return. Vitest's
193
- report remains authoritative for status, failures, timing, hierarchy, and
232
+ `matrix.workflow`, `matrix.task`, and `params` are always present, with `{}` for
233
+ empty scopes. Parameters are persisted in full, including fixed configuration;
234
+ there is no automatic redaction. Static coordinates and parameters survive setup
235
+ failures, skips, and retries. `output` is present only after a successful,
236
+ JSON-valid Task return and is cleared before every retry. Vitest's report remains authoritative for status, failures, timing, hierarchy, and
194
237
  retries.
195
238
 
196
239
  ## Notify Slack
197
240
 
198
241
  Invoker's optional Slack reporter posts one `Invoker Report` parent message per
199
242
  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
243
+ Workflow metadata, and a table with counts and durations per Workflow
244
+ coordinate/Task pair. Failures, retries, and skips identify both Matrix scopes.
245
+ Collection failures produce a failed Workflow card even without collected Cases.
246
+ Full parameters are persisted in JSON rather than printed automatically in Slack.
247
+ A shared footer
201
248
  contains the elapsed span from the first Case start to the final Case completion,
202
249
  a localized timestamp, and the optional run link. Final failures, successful
203
250
  retry details, skipped Case reasons, and unhandled run errors are posted in the
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ //#region src/errors.ts
3
+ const errorMessageSchema = z.object({ message: z.string() });
4
+ const errorStackSchema = z.object({ stack: z.string() });
5
+ function errorMessage(cause) {
6
+ const message = errorMessageSchema.safeParse(cause);
7
+ if (message.success) return message.data.message;
8
+ const stack = errorStackSchema.safeParse(cause);
9
+ if (stack.success) return stack.data.stack.split("\n", 1)[0];
10
+ return String(cause);
11
+ }
12
+ function errorWithContext(context, cause) {
13
+ return new Error(`${context}: ${errorMessage(cause)}`, { cause });
14
+ }
15
+ //#endregion
16
+ export { errorWithContext as n, errorMessage as t };
package/dist/index.d.mts CHANGED
@@ -11,63 +11,70 @@ type Matrix = {
11
11
  readonly [axis: string]: readonly JsonValue[];
12
12
  };
13
13
  type CaseCoordinates<M extends Matrix> = { readonly [K in keyof M]: M[K][number]; };
14
- interface InvokerMeta<Coordinates extends JsonObject, Output extends JsonValue, Metadata extends JsonObject = JsonObject> {
15
- schema: 1;
16
- matrix: Coordinates;
14
+ interface InvokerMeta<Coordinates extends JsonObject, Output extends JsonValue, Metadata extends JsonObject = JsonObject, WorkflowCoordinates extends JsonObject = JsonObject, Params extends JsonObject = JsonObject> {
15
+ schema: 2;
16
+ matrix: {
17
+ workflow: WorkflowCoordinates;
18
+ task: Coordinates;
19
+ };
20
+ params: Params;
17
21
  metadata?: Metadata;
18
22
  output?: Output;
19
23
  }
20
- interface TaskContext<Coordinates, Setup> {
24
+ interface TaskContext<Coordinates, Setup, Params = Record<never, never>> extends ParamsContext<Params> {
21
25
  readonly matrix: Coordinates;
22
26
  readonly setup: Setup;
23
27
  readonly vitest: TestContext;
24
28
  }
25
29
  type Awaitable<Value> = Value | PromiseLike<Value>;
26
- type SetupContext<M extends Matrix> = {
30
+ type DeepReadonly<Value> = JsonValue extends Value ? Value : { readonly [Key in keyof Value]: DeepReadonly<Value[Key]>; };
31
+ type ParamsContext<Params> = {
32
+ readonly params: DeepReadonly<Params>;
33
+ };
34
+ type SetupContext<M extends Matrix, Params = Record<never, never>> = ParamsContext<Params> & {
27
35
  readonly cases: readonly CaseCoordinates<M>[];
28
36
  };
29
- type TeardownContext<M extends Matrix, Setup> = SetupContext<M> & {
37
+ type TeardownContext<M extends Matrix, Setup, Params = Record<never, never>> = SetupContext<M, Params> & {
30
38
  readonly setup: Setup;
31
39
  };
32
40
  //#endregion
33
41
  //#region src/task.d.ts
34
- declare const taskDefinitionBrand: unique symbol;
35
- interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue> {
36
- readonly name: Name;
37
- readonly matrix: () => Promise<M>;
38
- readonly [taskDefinitionBrand]: true;
39
- readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
40
- readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
41
- readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
42
- }
43
- type TaskWithSetup<Name extends string, M extends Matrix, Setup, Output extends JsonValue> = {
44
- readonly name: Name;
45
- readonly matrix?: () => Promise<M>;
46
- readonly setup: (context: SetupContext<M>) => Awaitable<Setup>;
47
- readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
48
- readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
49
- };
50
- type TaskWithoutSetup<Name extends string, M extends Matrix, Output extends JsonValue> = {
42
+ declare const taskBindingBrand: unique symbol;
43
+ type ParameterArguments<Params> = [Params] extends [Record<string, never>] ? [] : [params: Params];
44
+ type RequiredParameters<Params> = { [Key in keyof Params]-?: Params[Key] extends JsonValue ? RequiredParameters<Params[Key]> : never; };
45
+ type TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue, Params extends JsonObject = Record<never, never>> = (...args: ParameterArguments<Params>) => BoundTask<Name, M, Setup, Output, Params>;
46
+ type TaskOptions<Name extends string, M extends Matrix, Setup, Output extends JsonValue, Params> = {
51
47
  readonly name: Name;
52
- readonly matrix?: () => Promise<M>;
53
- readonly setup?: never;
54
- readonly run: (context: TaskContext<CaseCoordinates<M>, undefined>) => Awaitable<Output>;
55
- readonly teardown?: never;
48
+ readonly matrix?: (context: ParamsContext<Params>) => Promise<M>;
49
+ readonly setup?: (context: SetupContext<M, Params>) => Awaitable<Setup>;
50
+ readonly run: (context: TaskContext<CaseCoordinates<M>, Setup, Params>) => Awaitable<Output>;
51
+ readonly teardown?: (context: TeardownContext<M, Setup, Params>) => Awaitable<void>;
56
52
  };
57
- declare function defineTask<const Name extends string, const M extends Matrix = Record<never, never>, Setup = unknown, const Output extends JsonValue = JsonValue>(definition: TaskWithSetup<Name, M, Setup, Output>): TaskDefinition<Name, M, Setup, Output>;
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
- type AnyTaskDefinition = {
60
- readonly name: string;
61
- readonly matrix: () => Promise<Matrix>;
62
- readonly [taskDefinitionBrand]: true;
53
+ interface BoundTask<Name extends string, M extends Matrix, Setup, Output extends JsonValue, Params extends JsonObject> extends TaskOptions<Name, M, Setup, Output, Params>, ParamsContext<Params> {
54
+ readonly matrix: (context: ParamsContext<Params>) => Promise<M>;
55
+ readonly [taskBindingBrand]: true;
56
+ }
57
+ declare function defineTask<Params extends Record<keyof Params, JsonValue> & RequiredParameters<Params> = Record<never, never>>(): {
58
+ <const Name extends string, const M extends Matrix = Record<never, never>, Setup = unknown, const Output extends JsonValue = JsonValue>(definition: TaskOptions<Name, M, Setup, Output, Params> & {
59
+ readonly setup: (context: SetupContext<M, Params>) => Awaitable<Setup>;
60
+ }): TaskDefinition<Name, M, Setup, Output, Params>;
61
+ <const Name extends string, const M extends Matrix = Record<never, never>, const Output extends JsonValue = JsonValue>(definition: TaskOptions<Name, M, undefined, Output, Params> & {
62
+ readonly setup?: never;
63
+ readonly teardown?: never;
64
+ }): TaskDefinition<Name, M, undefined, Output, Params>;
63
65
  };
66
+ type AnyTaskBinding = Pick<RuntimeTask, "name" | "params" | typeof taskBindingBrand>;
67
+ type RuntimeTask = BoundTask<string, Matrix, unknown, JsonValue, JsonObject>;
64
68
  //#endregion
65
69
  //#region src/workflow.d.ts
66
- type WorkflowDefinition<Tasks extends readonly [AnyTaskDefinition, ...AnyTaskDefinition[]], Metadata extends JsonObject> = {
70
+ type WorkflowDefinition<M extends Matrix, Metadata extends JsonObject> = {
67
71
  readonly name: string;
68
72
  readonly metadata?: Metadata;
69
- readonly tasks: Tasks;
73
+ readonly matrix?: () => Promise<M>;
74
+ readonly tasks: (context: {
75
+ readonly matrix: CaseCoordinates<M>;
76
+ }) => readonly [AnyTaskBinding, ...AnyTaskBinding[]];
70
77
  };
71
- declare function defineWorkflow<const Tasks extends readonly [AnyTaskDefinition, ...AnyTaskDefinition[]], const Metadata extends JsonObject = JsonObject>(definition: WorkflowDefinition<Tasks, Metadata>): void;
78
+ declare function defineWorkflow<const M extends Matrix = Record<never, never>, const Metadata extends JsonObject = JsonObject>(definition: WorkflowDefinition<M, Metadata>): void;
72
79
  //#endregion
73
80
  export { type CaseCoordinates, type InvokerMeta, type JsonObject, type JsonPrimitive, type JsonValue, type Matrix, type TaskContext, type TaskDefinition, defineTask, defineWorkflow };
package/dist/index.mjs CHANGED
@@ -1,13 +1,20 @@
1
- import { beforeAll, describe, test } from "vitest";
1
+ import { n as errorWithContext } from "./errors-CMczwLz9.mjs";
2
+ import { TestRunner, beforeAll, describe, test } from "vitest";
2
3
  import { z } from "zod";
3
4
  //#region src/task.ts
4
- const taskDefinitionBrand = Symbol("invoker.task");
5
- function defineTask(definition) {
6
- return {
7
- ...definition,
8
- matrix: definition.matrix ?? (async () => ({})),
9
- [taskDefinitionBrand]: true
10
- };
5
+ const taskBindingBrand = Symbol("invoker.task-binding");
6
+ function defineTask() {
7
+ function define(definition) {
8
+ return (...args) => {
9
+ return {
10
+ ...definition,
11
+ params: args.length === 0 ? {} : args[0],
12
+ matrix: definition.matrix ?? (async () => ({})),
13
+ [taskBindingBrand]: true
14
+ };
15
+ };
16
+ }
17
+ return define;
11
18
  }
12
19
  //#endregion
13
20
  //#region src/json.ts
@@ -18,8 +25,8 @@ const scalarSchema = z.union([
18
25
  z.number()
19
26
  ]);
20
27
  const nameSchema = z.string().trim().min(1);
21
- function snapshotJson(value, owner, path, ancestors = /* @__PURE__ */ new Set()) {
22
- return cloneJson(value, owner, path, ancestors);
28
+ function snapshotJson(value, owner, path) {
29
+ return cloneJson(value, owner, path, /* @__PURE__ */ new Set());
23
30
  }
24
31
  function cloneJson(value, owner, path, ancestors) {
25
32
  if (scalarSchema.safeParse(value).success) return value;
@@ -34,13 +41,12 @@ function cloneJson(value, owner, path, ancestors) {
34
41
  }
35
42
  ancestors.delete(value);
36
43
  return snapshot;
37
- } else {
38
- assertPlainObject(value, owner, path);
39
- if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
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;
43
44
  }
45
+ assertPlainObject(value, owner, path);
46
+ if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
47
+ const snapshot = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child, owner, `${path}.${key}`, ancestors)]));
48
+ ancestors.delete(value);
49
+ return snapshot;
44
50
  }
45
51
  function assertPlainObject(value, owner, path) {
46
52
  if (Object(value) !== value || Array.isArray(value)) fail(owner, path, "expected a plain object");
@@ -103,89 +109,155 @@ function caseName(matrix, index) {
103
109
  }
104
110
  //#endregion
105
111
  //#region src/workflow.ts
112
+ const functionSchema = z.function();
106
113
  function defineWorkflow(definition) {
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 }, () => {
110
- let setup;
111
- const setupTask = prepared.task.setup;
112
- if (setupTask) beforeAll(async () => {
113
- setup = await setupTask({ cases: prepared.cases });
114
- const teardownTask = prepared.task.teardown;
115
- if (teardownTask) return () => teardownTask({
116
- cases: prepared.cases,
117
- setup
118
- });
114
+ const info = { name: definition.name };
115
+ const meta = { invokerWorkflow: info };
116
+ describe(definition.name, {
117
+ concurrent: false,
118
+ shuffle: false,
119
+ meta
120
+ }, async () => {
121
+ const file = TestRunner.getCurrentSuite().file;
122
+ try {
123
+ const coordinates = await prepareWorkflow(definition, info);
124
+ for (const [index, coordinate] of coordinates.entries()) describe(caseName(coordinate.matrix, index), {
125
+ concurrent: false,
126
+ meta: { invokerWorkflow: null }
127
+ }, () => {
128
+ for (const prepared of coordinate.tasks) registerTask(prepared, coordinate.matrix, info.metadata);
129
+ });
130
+ } catch (cause) {
131
+ Object.assign(file.meta, { invokerCollectionError: info });
132
+ throw errorWithContext(`Workflow ${JSON.stringify(info.name)} collection failed`, cause);
133
+ }
134
+ });
135
+ }
136
+ function registerTask(prepared, workflow, metadata) {
137
+ const { task, cases } = prepared;
138
+ const { params } = task;
139
+ describe(task.name, { concurrent: false }, () => {
140
+ let setup;
141
+ const setupTask = task.setup;
142
+ if (setupTask) beforeAll(async () => {
143
+ setup = await setupTask({
144
+ params,
145
+ cases
146
+ });
147
+ const teardownTask = task.teardown;
148
+ if (teardownTask) return () => teardownTask({
149
+ params,
150
+ cases,
151
+ setup
119
152
  });
120
- for (const [index, matrix] of prepared.cases.entries()) {
121
- const name = prepared.names[index];
122
- const invoker = prepared.metadata[index];
123
- test.concurrent(name, { meta: { invoker } }, async (vitest) => {
124
- const meta = vitest.task.meta;
125
- delete meta.invoker.output;
126
- const output = await prepared.task.run({
127
- matrix,
128
- setup,
129
- vitest
130
- });
131
- meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(prepared.task.name)}`, ".output");
132
- });
133
- }
134
153
  });
154
+ for (const [index, matrix] of cases.entries()) {
155
+ const invoker = {
156
+ schema: 2,
157
+ matrix: {
158
+ workflow,
159
+ task: matrix
160
+ },
161
+ params
162
+ };
163
+ if (metadata !== void 0) invoker.metadata = metadata;
164
+ test.concurrent(caseName(matrix, index), { meta: { invoker } }, async (vitest) => {
165
+ const meta = vitest.task.meta;
166
+ delete meta.invoker.output;
167
+ const output = await task.run({
168
+ params,
169
+ matrix,
170
+ setup,
171
+ vitest
172
+ });
173
+ meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(task.name)}`, ".output");
174
+ });
175
+ }
135
176
  });
136
177
  }
137
- async function prepareWorkflow(definition) {
178
+ async function prepareWorkflow(definition, info) {
138
179
  assertPlainObject(definition, "Workflow", "");
139
180
  assertOnlyKeys(definition, [
140
181
  "name",
141
182
  "metadata",
183
+ "matrix",
142
184
  "tasks"
143
185
  ], "Workflow");
144
- const { name, metadata: workflowMetadata, tasks: taskDefinitions } = definition;
145
- assertName(name, "Workflow", ".name");
146
- const metadata = workflowMetadata === void 0 ? void 0 : snapshotJson(workflowMetadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
147
- if (metadata !== void 0) assertPlainObject(metadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
148
- if (!Array.isArray(taskDefinitions) || taskDefinitions.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
149
- const names = /* @__PURE__ */ new Set();
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;
186
+ assertName(definition.name, "Workflow", ".name");
187
+ const owner = `Workflow ${JSON.stringify(definition.name)}`;
188
+ if (definition.metadata !== void 0) {
189
+ const metadata = snapshotJson(definition.metadata, owner, ".metadata");
190
+ assertPlainObject(metadata, owner, ".metadata");
191
+ info.metadata = metadata;
192
+ }
193
+ if (!functionSchema.safeParse(definition.tasks).success) fail(owner, ".tasks", "expected a synchronous function");
194
+ if (definition.matrix !== void 0 && !functionSchema.safeParse(definition.matrix).success) fail(owner, ".matrix", "expected a function");
195
+ const coordinates = expandMatrix(definition.matrix ? await definition.matrix() : {}, owner).map((coordinate, index) => {
196
+ const coordinateOwner = `${owner} coordinate ${caseName(coordinate, index)}`;
197
+ return {
198
+ matrix: coordinate,
199
+ owner: coordinateOwner,
200
+ tasks: bindTasks(definition, coordinate, coordinateOwner)
201
+ };
172
202
  });
173
- return await Promise.all(tasks.map(async (task) => {
174
- const cases = expandMatrix(await task.matrix(), `Task ${JSON.stringify(task.name)}`);
203
+ return Promise.all(coordinates.map(async (coordinate) => ({
204
+ matrix: coordinate.matrix,
205
+ tasks: await Promise.all(coordinate.tasks.map((task) => discoverTask(task, coordinate.owner)))
206
+ })));
207
+ }
208
+ function bindTasks(definition, matrix, owner) {
209
+ try {
210
+ const bindings = definition.tasks({ matrix });
211
+ if (!Array.isArray(bindings) || bindings.length === 0) fail(owner, ".tasks", "expected a non-empty Task tuple");
212
+ const names = /* @__PURE__ */ new Set();
213
+ return bindings.map((binding) => prepareBinding(binding, owner, names));
214
+ } catch (cause) {
215
+ throw errorWithContext(`${owner} Task binding failed`, cause);
216
+ }
217
+ }
218
+ async function discoverTask(task, owner) {
219
+ const taskOwner = `${owner} Task ${JSON.stringify(task.name)}`;
220
+ try {
175
221
  return {
176
222
  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
- })
223
+ cases: expandMatrix(await task.matrix({ params: task.params }), taskOwner)
187
224
  };
188
- }));
225
+ } catch (cause) {
226
+ throw errorWithContext(`${taskOwner} Matrix discovery failed`, cause);
227
+ }
228
+ }
229
+ function prepareBinding(binding, owner, names) {
230
+ assertPlainObject(binding, owner, ".tasks");
231
+ if (binding[taskBindingBrand] !== true) fail(owner, ".tasks", "expected a bound Task created by defineTask");
232
+ const task = binding;
233
+ assertOnlyKeys(task, [
234
+ "name",
235
+ "params",
236
+ "matrix",
237
+ "setup",
238
+ "run",
239
+ "teardown"
240
+ ], owner);
241
+ assertName(task.name, owner, ".tasks.name");
242
+ if (names.has(task.name)) fail(owner, ".tasks", `duplicate Task name ${JSON.stringify(task.name)}`);
243
+ names.add(task.name);
244
+ const taskOwner = `${owner} Task ${JSON.stringify(task.name)}`;
245
+ for (const key of [
246
+ "matrix",
247
+ "run",
248
+ "setup",
249
+ "teardown"
250
+ ]) {
251
+ if ((key === "setup" || key === "teardown") && task[key] === void 0) continue;
252
+ if (!functionSchema.safeParse(task[key]).success) fail(taskOwner, `.${key}`, "expected a function");
253
+ }
254
+ if (task.teardown && !task.setup) fail(taskOwner, ".teardown", "requires setup");
255
+ const params = snapshotJson(task.params, taskOwner, ".params");
256
+ assertPlainObject(params, taskOwner, ".params");
257
+ return {
258
+ ...task,
259
+ params
260
+ };
189
261
  }
190
262
  //#endregion
191
263
  export { defineTask, defineWorkflow };
package/dist/slack.mjs CHANGED
@@ -1,68 +1,89 @@
1
+ import { t as errorMessage } from "./errors-CMczwLz9.mjs";
1
2
  import { z } from "zod";
2
3
  import { WebAPIRateLimitedError, WebClient } from "@slack/web-api";
3
4
  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,
5
+ //#region src/slack/collect.ts
6
+ const jsonObjectSchema = z.record(z.string(), z.json());
7
+ const workflowInfoSchema = z.object({
8
+ name: z.string(),
9
+ metadata: jsonObjectSchema.optional()
10
+ });
11
+ const workflowMetaSchema = z.object({ invokerWorkflow: workflowInfoSchema });
12
+ const collectionErrorSchema = z.object({ invokerCollectionError: workflowInfoSchema });
13
+ const testMetaSchema = z.object({ invoker: z.strictObject({
14
+ schema: z.literal(2),
15
+ matrix: z.strictObject({
16
+ workflow: jsonObjectSchema,
17
+ task: jsonObjectSchema
18
+ }),
19
+ params: jsonObjectSchema,
10
20
  metadata: jsonObjectSchema.optional(),
11
21
  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;
22
+ }) });
21
23
  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);
24
+ const workflows = [];
25
+ for (const module of modules) {
26
+ for (const suite of module.children.allSuites()) {
27
+ const meta = workflowMetaSchema.safeParse(suite.meta());
28
+ if (!meta.success) continue;
29
+ const tasks = [...suite.children.suites()].flatMap((coordinate) => [...coordinate.children.suites()].map((task) => collectTask(task, coordinate.name)));
30
+ const failures = [];
31
+ addErrors(failures, suite.errors());
32
+ for (const coordinate of suite.children.suites()) addErrors(failures, coordinate.errors(), coordinate.name);
33
+ workflows.push({
34
+ ...meta.data.invokerWorkflow,
35
+ tasks: tasks.map((task) => task.report),
36
+ failures: deduplicateFailures([...failures, ...tasks.flatMap((task) => task.failures)]),
37
+ retries: tasks.flatMap((task) => task.retries),
38
+ skips: tasks.flatMap((task) => task.skips),
39
+ ...timeSpan(tasks)
40
+ });
39
41
  }
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: [],
42
+ const collection = collectionErrorSchema.safeParse(module.meta());
43
+ if (collection.success) {
44
+ const failures = [];
45
+ addErrors(failures, module.errors());
46
+ workflows.push({
47
+ ...collection.data.invokerCollectionError,
48
+ tasks: [],
49
+ failures,
52
50
  retries: [],
53
51
  skips: []
54
- };
55
- workflow.tasks.set(taskSuite, task);
52
+ });
56
53
  }
57
- task.total += 1;
54
+ }
55
+ return workflows;
56
+ }
57
+ function collectTask(suite, coordinateName) {
58
+ const name = `${coordinateName} · ${suite.name}`;
59
+ const report = {
60
+ name,
61
+ total: 0,
62
+ passed: 0,
63
+ retried: 0,
64
+ failed: 0,
65
+ skipped: 0,
66
+ incomplete: 0,
67
+ duration: 0
68
+ };
69
+ const failures = [];
70
+ const retries = [];
71
+ const skips = [];
72
+ let startedAt;
73
+ let endedAt;
74
+ for (const testCase of suite.children.tests()) {
75
+ const meta = testMetaSchema.safeParse(testCase.meta());
76
+ if (!meta.success) continue;
77
+ const { invoker } = meta.data;
58
78
  const result = testCase.result();
59
79
  const diagnostic = testCase.diagnostic();
60
- task[result.state === "pending" ? "incomplete" : result.state] += 1;
80
+ report.total += 1;
81
+ report[result.state === "pending" ? "incomplete" : result.state] += 1;
61
82
  const retryCount = diagnostic?.retryCount ?? 0;
62
83
  if (retryCount > 0) {
63
- task.retried += 1;
64
- if (result.state === "passed") task.retries.push({
65
- task: task.name,
84
+ report.retried += 1;
85
+ if (result.state === "passed") retries.push({
86
+ task: name,
66
87
  caseName: testCase.name,
67
88
  matrix: invoker.matrix,
68
89
  count: retryCount,
@@ -70,51 +91,80 @@ function collectWorkflowReports(modules) {
70
91
  });
71
92
  }
72
93
  if (diagnostic) {
73
- task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
74
- task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
94
+ startedAt = Math.min(startedAt ?? diagnostic.startTime, diagnostic.startTime);
95
+ endedAt = Math.max(endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
75
96
  }
76
- if (result.state === "failed") task.failures.push({
77
- task: task.name,
97
+ if (result.state === "failed") failures.push({
98
+ task: name,
78
99
  caseName: testCase.name,
79
100
  matrix: invoker.matrix,
80
101
  messages: result.errors.map(errorMessage)
81
102
  });
82
- else if (result.state === "skipped") task.skips.push({
83
- task: task.name,
103
+ else if (result.state === "skipped") skips.push({
104
+ task: name,
84
105
  caseName: testCase.name,
85
106
  matrix: invoker.matrix,
86
107
  reason: result.note || "No reason provided"
87
108
  });
88
109
  }
89
- return [...workflows.values()].map((workflow) => {
90
- const failures = [];
91
- const retries = [];
92
- const skips = [];
93
- addErrors(failures, workflow.suite.errors());
94
- addErrors(failures, workflow.module.errors());
95
- const collectedTasks = [...workflow.tasks.values()];
96
- for (const task of collectedTasks) {
97
- failures.push(...task.failures);
98
- retries.push(...task.retries);
99
- skips.push(...task.skips);
100
- addErrors(failures, task.suite.errors(), task.name);
101
- }
102
- const { startedAt, endedAt } = timeSpan(collectedTasks);
103
- return {
104
- name: workflow.name,
105
- metadata: workflow.metadata,
106
- tasks: collectedTasks.map(({ suite: _suite, failures: _failures, retries: _retries, skips: _skips, startedAt, endedAt, ...task }) => ({
107
- ...task,
108
- duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
109
- })),
110
- failures: deduplicateFailures(failures),
111
- retries,
112
- skips,
113
- startedAt,
114
- endedAt
115
- };
110
+ addErrors(failures, suite.errors(), name);
111
+ report.duration = startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt;
112
+ return {
113
+ report,
114
+ failures,
115
+ retries,
116
+ skips,
117
+ startedAt,
118
+ endedAt
119
+ };
120
+ }
121
+ function addErrors(failures, errors, task) {
122
+ const messages = errors.map(errorMessage);
123
+ if (messages.length > 0) failures.push({
124
+ task,
125
+ messages
116
126
  });
117
127
  }
128
+ function deduplicateFailures(failures) {
129
+ const seen = /* @__PURE__ */ new Set();
130
+ return failures.flatMap((failure) => {
131
+ const messages = failure.messages.filter((message) => {
132
+ const key = JSON.stringify([
133
+ failure.task,
134
+ failure.caseName,
135
+ failure.matrix,
136
+ message
137
+ ]);
138
+ if (seen.has(key)) return false;
139
+ seen.add(key);
140
+ return true;
141
+ });
142
+ return messages.length > 0 ? [{
143
+ ...failure,
144
+ messages
145
+ }] : [];
146
+ });
147
+ }
148
+ function timeSpan(values) {
149
+ let startedAt;
150
+ let endedAt;
151
+ for (const value of values) {
152
+ if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
153
+ if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
154
+ }
155
+ return {
156
+ startedAt,
157
+ endedAt
158
+ };
159
+ }
160
+ //#endregion
161
+ //#region src/slack/report.ts
162
+ const stringSchema = z.string();
163
+ const TABLE_ROW_LIMIT = 100;
164
+ const TABLE_CHARACTER_LIMIT = 1e4;
165
+ const SECTION_CHARACTER_LIMIT = 3e3;
166
+ const NAME_CHARACTER_LIMIT = 200;
167
+ const METADATA_CHARACTER_LIMIT = 1800;
118
168
  function summaryMessage(reports, runUrl) {
119
169
  const timestamp = Math.floor(Date.now() / 1e3);
120
170
  const { startedAt, endedAt } = timeSpan(reports);
@@ -143,7 +193,8 @@ function workflowAttachment(report, rows, index, pages) {
143
193
  const status = workflowStatus(report);
144
194
  const metadata = metadataText(report.metadata);
145
195
  const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
146
- const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${totals.passed}/${totals.total} passed${page}*`;
196
+ const result = report.tasks.length === 0 ? "Collection failed" : `${totals.passed}/${totals.total} passed`;
197
+ const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${result}${page}*`;
147
198
  return {
148
199
  color: status.color,
149
200
  blocks: [
@@ -178,131 +229,66 @@ function workflowAttachment(report, rows, index, pages) {
178
229
  }
179
230
  function failureMessages(report) {
180
231
  return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
181
- const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
182
- const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
183
- const metadata = metadataText(report.metadata);
184
- const context = metadata ? `${workflow} • ${metadata}` : workflow;
185
- return chunk(failures.map((failure) => {
232
+ return detailMessages(report, task, "failed", failures.map((failure) => {
186
233
  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("")}`;
187
- }), 3e3).map((details, index, messages) => {
188
- const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
189
- return {
190
- text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
191
- attachments: [{
192
- color: "danger",
193
- blocks: [
194
- {
195
- type: "section",
196
- text: {
197
- type: "mrkdwn",
198
- text: `🔴 *${title} — ${failures.length} failed${part}*`
199
- }
200
- },
201
- {
202
- type: "context",
203
- elements: [{
204
- type: "mrkdwn",
205
- text: context
206
- }]
207
- },
208
- {
209
- type: "section",
210
- text: {
211
- type: "mrkdwn",
212
- text: details
213
- }
214
- }
215
- ]
216
- }]
217
- };
218
- });
234
+ }));
219
235
  });
220
236
  }
221
237
  function retryMessages(report) {
222
238
  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) => {
239
+ return detailMessages(report, task, "retried", retries.map((retry) => {
228
240
  const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
229
241
  const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
230
242
  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
- });
243
+ }));
263
244
  });
264
245
  }
265
246
  function skipMessages(report) {
266
247
  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) => {
248
+ return detailMessages(report, task, "skipped", skips.map((skip) => {
272
249
  const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(skip.matrix))}`;
273
250
  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
- }
251
+ }));
252
+ });
253
+ }
254
+ function detailMessages(report, task, outcome, entries) {
255
+ const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
256
+ const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
257
+ const metadata = metadataText(report.metadata);
258
+ const context = metadata ? `${workflow} • ${metadata}` : workflow;
259
+ const emoji = outcome === "failed" ? "🔴" : "🟡";
260
+ return chunk(entries, SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
261
+ const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
262
+ const count = `${entries.length} ${outcome}${part}`;
263
+ return {
264
+ text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${count}`,
265
+ attachments: [{
266
+ color: outcome === "failed" ? "danger" : "warning",
267
+ blocks: [
268
+ {
269
+ type: "section",
270
+ text: {
271
+ type: "mrkdwn",
272
+ text: `${emoji} *${title} — ${count}*`
301
273
  }
302
- ]
303
- }]
304
- };
305
- });
274
+ },
275
+ {
276
+ type: "context",
277
+ elements: [{
278
+ type: "mrkdwn",
279
+ text: context
280
+ }]
281
+ },
282
+ {
283
+ type: "section",
284
+ text: {
285
+ type: "mrkdwn",
286
+ text: details
287
+ }
288
+ }
289
+ ]
290
+ }]
291
+ };
306
292
  });
307
293
  }
308
294
  function unhandledErrorMessages(errors) {
@@ -330,40 +316,6 @@ function unhandledErrorMessages(errors) {
330
316
  };
331
317
  });
332
318
  }
333
- function addErrors(failures, errors, task) {
334
- const messages = errors.map(errorMessage);
335
- if (messages.length > 0) failures.push({
336
- task,
337
- messages
338
- });
339
- }
340
- function errorMessage(cause) {
341
- const message = errorMessageSchema.safeParse(cause);
342
- if (message.success) return message.data.message;
343
- const stack = errorStackSchema.safeParse(cause);
344
- if (stack.success) return stack.data.stack.split("\n", 1)[0];
345
- return String(cause);
346
- }
347
- function deduplicateFailures(failures) {
348
- const seen = /* @__PURE__ */ new Set();
349
- return failures.flatMap((failure) => {
350
- const messages = failure.messages.filter((message) => {
351
- const key = JSON.stringify([
352
- failure.task,
353
- failure.caseName,
354
- failure.matrix,
355
- message
356
- ]);
357
- if (seen.has(key)) return false;
358
- seen.add(key);
359
- return true;
360
- });
361
- return messages.length > 0 ? [{
362
- ...failure,
363
- messages
364
- }] : [];
365
- });
366
- }
367
319
  function escapeSlack(value) {
368
320
  return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
369
321
  }
@@ -451,7 +403,7 @@ function formatDuration(milliseconds) {
451
403
  function chunk(entries, limit) {
452
404
  const chunks = [];
453
405
  for (const entry of entries) {
454
- const value = entry.length > limit ? `${entry.slice(0, limit - 1)}…` : entry;
406
+ const value = truncate(entry, limit);
455
407
  const previous = chunks.at(-1);
456
408
  if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
457
409
  else chunks.push(value);
@@ -461,18 +413,6 @@ function chunk(entries, limit) {
461
413
  function truncate(value, limit) {
462
414
  return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
463
415
  }
464
- function timeSpan(values) {
465
- let startedAt;
466
- let endedAt;
467
- for (const value of values) {
468
- if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
469
- if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
470
- }
471
- return {
472
- startedAt,
473
- endedAt
474
- };
475
- }
476
416
  //#endregion
477
417
  //#region src/slack/reporter.ts
478
418
  function slackReporter(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshatmittal/invoker",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Typed matrix-driven regression workflows for Vitest.",
5
5
  "keywords": [
6
6
  "matrix",