@akshatmittal/invoker 0.3.0 → 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 +81 -36
- package/dist/errors-CMczwLz9.mjs +16 -0
- package/dist/index.d.mts +43 -36
- package/dist/index.mjs +156 -84
- package/dist/slack.mjs +195 -201
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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,34 +214,43 @@ 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
|
-
|
|
217
|
+
Schema 2 keeps both coordinate scopes and all bound parameters in the JSON report:
|
|
182
218
|
|
|
183
219
|
```json
|
|
184
220
|
{
|
|
185
|
-
"schema":
|
|
186
|
-
"matrix": {
|
|
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": { "
|
|
228
|
+
"output": { "baseline": "2026-09-01", "score": 0.92 }
|
|
189
229
|
}
|
|
190
230
|
```
|
|
191
231
|
|
|
192
|
-
`
|
|
193
|
-
|
|
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
|
-
Vitest run
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
242
|
+
Vitest run containing every Workflow card. Each card includes aggregate results,
|
|
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
|
|
248
|
+
contains the elapsed span from the first Case start to the final Case completion,
|
|
249
|
+
a localized timestamp, and the optional run link. Final failures, successful
|
|
250
|
+
retry details, skipped Case reasons, and unhandled run errors are posted in the
|
|
251
|
+
same thread. Delivery failures are isolated to the affected reply. Ambiguous
|
|
252
|
+
transport failures are not retried; an explicit Slack rate-limit rejection is
|
|
253
|
+
reattempted only after its required delay.
|
|
209
254
|
|
|
210
255
|
```ts
|
|
211
256
|
import { slackReporter } from "@akshatmittal/invoker/slack";
|
|
@@ -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:
|
|
16
|
-
matrix:
|
|
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
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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?:
|
|
54
|
-
readonly run: (context: TaskContext<CaseCoordinates<M>,
|
|
55
|
-
readonly teardown?:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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<
|
|
70
|
+
type WorkflowDefinition<M extends Matrix, Metadata extends JsonObject> = {
|
|
67
71
|
readonly name: string;
|
|
68
72
|
readonly metadata?: Metadata;
|
|
69
|
-
readonly
|
|
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
|
|
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 {
|
|
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
|
|
5
|
-
function defineTask(
|
|
6
|
-
|
|
7
|
-
...
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
22
|
-
return cloneJson(value, owner, path,
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
174
|
-
|
|
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,67 +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/
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
23
|
-
for (const module of modules)
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
failures: [],
|
|
52
|
-
retries: []
|
|
53
|
-
};
|
|
54
|
-
workflow.tasks.set(taskSuite, task);
|
|
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,
|
|
50
|
+
retries: [],
|
|
51
|
+
skips: []
|
|
52
|
+
});
|
|
55
53
|
}
|
|
56
|
-
|
|
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;
|
|
57
78
|
const result = testCase.result();
|
|
58
79
|
const diagnostic = testCase.diagnostic();
|
|
59
|
-
|
|
80
|
+
report.total += 1;
|
|
81
|
+
report[result.state === "pending" ? "incomplete" : result.state] += 1;
|
|
60
82
|
const retryCount = diagnostic?.retryCount ?? 0;
|
|
61
83
|
if (retryCount > 0) {
|
|
62
|
-
|
|
63
|
-
if (result.state === "passed")
|
|
64
|
-
task:
|
|
84
|
+
report.retried += 1;
|
|
85
|
+
if (result.state === "passed") retries.push({
|
|
86
|
+
task: name,
|
|
65
87
|
caseName: testCase.name,
|
|
66
88
|
matrix: invoker.matrix,
|
|
67
89
|
count: retryCount,
|
|
@@ -69,42 +91,80 @@ function collectWorkflowReports(modules) {
|
|
|
69
91
|
});
|
|
70
92
|
}
|
|
71
93
|
if (diagnostic) {
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
startedAt = Math.min(startedAt ?? diagnostic.startTime, diagnostic.startTime);
|
|
95
|
+
endedAt = Math.max(endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
|
|
74
96
|
}
|
|
75
|
-
if (result.state === "failed")
|
|
76
|
-
task:
|
|
97
|
+
if (result.state === "failed") failures.push({
|
|
98
|
+
task: name,
|
|
77
99
|
caseName: testCase.name,
|
|
78
100
|
matrix: invoker.matrix,
|
|
79
101
|
messages: result.errors.map(errorMessage)
|
|
80
102
|
});
|
|
103
|
+
else if (result.state === "skipped") skips.push({
|
|
104
|
+
task: name,
|
|
105
|
+
caseName: testCase.name,
|
|
106
|
+
matrix: invoker.matrix,
|
|
107
|
+
reason: result.note || "No reason provided"
|
|
108
|
+
});
|
|
81
109
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
126
|
+
});
|
|
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
|
+
}] : [];
|
|
106
146
|
});
|
|
107
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;
|
|
108
168
|
function summaryMessage(reports, runUrl) {
|
|
109
169
|
const timestamp = Math.floor(Date.now() / 1e3);
|
|
110
170
|
const { startedAt, endedAt } = timeSpan(reports);
|
|
@@ -133,7 +193,8 @@ function workflowAttachment(report, rows, index, pages) {
|
|
|
133
193
|
const status = workflowStatus(report);
|
|
134
194
|
const metadata = metadataText(report.metadata);
|
|
135
195
|
const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
|
|
136
|
-
const
|
|
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}*`;
|
|
137
198
|
return {
|
|
138
199
|
color: status.color,
|
|
139
200
|
blocks: [
|
|
@@ -168,88 +229,66 @@ function workflowAttachment(report, rows, index, pages) {
|
|
|
168
229
|
}
|
|
169
230
|
function failureMessages(report) {
|
|
170
231
|
return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
|
|
171
|
-
|
|
172
|
-
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
173
|
-
const metadata = metadataText(report.metadata);
|
|
174
|
-
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
175
|
-
return chunk(failures.map((failure) => {
|
|
232
|
+
return detailMessages(report, task, "failed", failures.map((failure) => {
|
|
176
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("")}`;
|
|
177
|
-
})
|
|
178
|
-
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
179
|
-
return {
|
|
180
|
-
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
|
|
181
|
-
attachments: [{
|
|
182
|
-
color: "danger",
|
|
183
|
-
blocks: [
|
|
184
|
-
{
|
|
185
|
-
type: "section",
|
|
186
|
-
text: {
|
|
187
|
-
type: "mrkdwn",
|
|
188
|
-
text: `🔴 *${title} — ${failures.length} failed${part}*`
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
type: "context",
|
|
193
|
-
elements: [{
|
|
194
|
-
type: "mrkdwn",
|
|
195
|
-
text: context
|
|
196
|
-
}]
|
|
197
|
-
},
|
|
198
|
-
{
|
|
199
|
-
type: "section",
|
|
200
|
-
text: {
|
|
201
|
-
type: "mrkdwn",
|
|
202
|
-
text: details
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
]
|
|
206
|
-
}]
|
|
207
|
-
};
|
|
208
|
-
});
|
|
234
|
+
}));
|
|
209
235
|
});
|
|
210
236
|
}
|
|
211
237
|
function retryMessages(report) {
|
|
212
238
|
return [...Map.groupBy(report.retries, (retry) => retry.task)].flatMap(([task, retries]) => {
|
|
213
|
-
|
|
214
|
-
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
215
|
-
const metadata = metadataText(report.metadata);
|
|
216
|
-
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
217
|
-
return chunk(retries.map((retry) => {
|
|
239
|
+
return detailMessages(report, task, "retried", retries.map((retry) => {
|
|
218
240
|
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
|
|
219
241
|
const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
|
|
220
242
|
return `*${escapeSlack(retry.caseName)}*\nRetries: ${retry.count}${matrix}${messages}`;
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function skipMessages(report) {
|
|
247
|
+
return [...Map.groupBy(report.skips, (skip) => skip.task)].flatMap(([task, skips]) => {
|
|
248
|
+
return detailMessages(report, task, "skipped", skips.map((skip) => {
|
|
249
|
+
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(skip.matrix))}`;
|
|
250
|
+
return `*${escapeSlack(skip.caseName)}*${matrix}\nReason: ${escapeSlack(skip.reason)}`;
|
|
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}*`
|
|
248
273
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
};
|
|
253
292
|
});
|
|
254
293
|
}
|
|
255
294
|
function unhandledErrorMessages(errors) {
|
|
@@ -277,40 +316,6 @@ function unhandledErrorMessages(errors) {
|
|
|
277
316
|
};
|
|
278
317
|
});
|
|
279
318
|
}
|
|
280
|
-
function addErrors(failures, errors, task) {
|
|
281
|
-
const messages = errors.map(errorMessage);
|
|
282
|
-
if (messages.length > 0) failures.push({
|
|
283
|
-
task,
|
|
284
|
-
messages
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
function errorMessage(cause) {
|
|
288
|
-
const message = errorMessageSchema.safeParse(cause);
|
|
289
|
-
if (message.success) return message.data.message;
|
|
290
|
-
const stack = errorStackSchema.safeParse(cause);
|
|
291
|
-
if (stack.success) return stack.data.stack.split("\n", 1)[0];
|
|
292
|
-
return String(cause);
|
|
293
|
-
}
|
|
294
|
-
function deduplicateFailures(failures) {
|
|
295
|
-
const seen = /* @__PURE__ */ new Set();
|
|
296
|
-
return failures.flatMap((failure) => {
|
|
297
|
-
const messages = failure.messages.filter((message) => {
|
|
298
|
-
const key = JSON.stringify([
|
|
299
|
-
failure.task,
|
|
300
|
-
failure.caseName,
|
|
301
|
-
failure.matrix,
|
|
302
|
-
message
|
|
303
|
-
]);
|
|
304
|
-
if (seen.has(key)) return false;
|
|
305
|
-
seen.add(key);
|
|
306
|
-
return true;
|
|
307
|
-
});
|
|
308
|
-
return messages.length > 0 ? [{
|
|
309
|
-
...failure,
|
|
310
|
-
messages
|
|
311
|
-
}] : [];
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
319
|
function escapeSlack(value) {
|
|
315
320
|
return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
|
|
316
321
|
}
|
|
@@ -398,7 +403,7 @@ function formatDuration(milliseconds) {
|
|
|
398
403
|
function chunk(entries, limit) {
|
|
399
404
|
const chunks = [];
|
|
400
405
|
for (const entry of entries) {
|
|
401
|
-
const value = entry
|
|
406
|
+
const value = truncate(entry, limit);
|
|
402
407
|
const previous = chunks.at(-1);
|
|
403
408
|
if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
|
|
404
409
|
else chunks.push(value);
|
|
@@ -408,18 +413,6 @@ function chunk(entries, limit) {
|
|
|
408
413
|
function truncate(value, limit) {
|
|
409
414
|
return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
410
415
|
}
|
|
411
|
-
function timeSpan(values) {
|
|
412
|
-
let startedAt;
|
|
413
|
-
let endedAt;
|
|
414
|
-
for (const value of values) {
|
|
415
|
-
if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
|
|
416
|
-
if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
|
|
417
|
-
}
|
|
418
|
-
return {
|
|
419
|
-
startedAt,
|
|
420
|
-
endedAt
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
416
|
//#endregion
|
|
424
417
|
//#region src/slack/reporter.ts
|
|
425
418
|
function slackReporter(options) {
|
|
@@ -457,6 +450,7 @@ function slackReporter(options) {
|
|
|
457
450
|
const replies = [
|
|
458
451
|
...reports.flatMap(failureMessages),
|
|
459
452
|
...reports.flatMap(retryMessages),
|
|
453
|
+
...reports.flatMap(skipMessages),
|
|
460
454
|
...unhandledErrorMessages(unhandledErrors)
|
|
461
455
|
];
|
|
462
456
|
if (replies.length > 0 && !parentTimestamp) {
|