@griffin-app/griffin-ts 0.1.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/ASSERTIONS_QUICK_REF.md +161 -0
- package/README.md +297 -0
- package/dist/assertions.d.ts +136 -0
- package/dist/assertions.d.ts.map +1 -0
- package/dist/assertions.js +230 -0
- package/dist/assertions.js.map +1 -0
- package/dist/builder.d.ts +168 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +165 -0
- package/dist/builder.js.map +1 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/example-sequential.d.ts +11 -0
- package/dist/example-sequential.d.ts.map +1 -0
- package/dist/example-sequential.js +160 -0
- package/dist/example-sequential.js.map +1 -0
- package/dist/example.d.ts +9 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +36 -0
- package/dist/example.js.map +1 -0
- package/dist/frequency.d.ts +15 -0
- package/dist/frequency.d.ts.map +1 -0
- package/dist/frequency.js +34 -0
- package/dist/frequency.js.map +1 -0
- package/dist/http-methods.d.ts +6 -0
- package/dist/http-methods.d.ts.map +1 -0
- package/dist/http-methods.js +9 -0
- package/dist/http-methods.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/response-formats.d.ts +4 -0
- package/dist/response-formats.d.ts.map +1 -0
- package/dist/response-formats.js +7 -0
- package/dist/response-formats.js.map +1 -0
- package/dist/schema-exports.d.ts +6 -0
- package/dist/schema-exports.d.ts.map +1 -0
- package/dist/schema-exports.js +43 -0
- package/dist/schema-exports.js.map +1 -0
- package/dist/schema.d.ts +311 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +214 -0
- package/dist/schema.js.map +1 -0
- package/dist/secrets.d.ts +56 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +74 -0
- package/dist/secrets.js.map +1 -0
- package/dist/sequential-builder.d.ts +127 -0
- package/dist/sequential-builder.d.ts.map +1 -0
- package/dist/sequential-builder.js +128 -0
- package/dist/sequential-builder.js.map +1 -0
- package/dist/shared.d.ts +8 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +36 -0
- package/dist/shared.js.map +1 -0
- package/dist/target.d.ts +33 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +53 -0
- package/dist/target.js.map +1 -0
- package/dist/type-exports.d.ts +6 -0
- package/dist/type-exports.d.ts.map +1 -0
- package/dist/type-exports.js +7 -0
- package/dist/type-exports.js.map +1 -0
- package/dist/wait.d.ts +9 -0
- package/dist/wait.d.ts.map +1 -0
- package/dist/wait.js +8 -0
- package/dist/wait.js.map +1 -0
- package/package.json +43 -0
- package/src/assertions.ts +327 -0
- package/src/builder.ts +336 -0
- package/src/constants.ts +5 -0
- package/src/example-sequential.ts +191 -0
- package/src/example.ts +55 -0
- package/src/frequency.ts +38 -0
- package/src/http-methods.ts +5 -0
- package/src/index.ts +70 -0
- package/src/response-formats.ts +3 -0
- package/src/schema-exports.ts +43 -0
- package/src/schema.ts +289 -0
- package/src/secrets.ts +112 -0
- package/src/sequential-builder.ts +254 -0
- package/src/shared.ts +46 -0
- package/src/target.ts +55 -0
- package/src/type-exports.ts +20 -0
- package/src/wait.ts +4 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich assertion DSL for constructing type-safe test assertions with JSONPath support.
|
|
3
|
+
*
|
|
4
|
+
* This module provides:
|
|
5
|
+
* - StateProxy: Tracks node access patterns and converts them to JSONPath
|
|
6
|
+
* - Assert: Builder for creating assertions with fluent API
|
|
7
|
+
* - Support for unary and binary predicates with negation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Identifies which node and which part of its result we're asserting on
|
|
16
|
+
*/
|
|
17
|
+
export interface PathDescriptor {
|
|
18
|
+
nodeId: string;
|
|
19
|
+
accessor: "body" | "headers" | "status";
|
|
20
|
+
path: string[]; // JSONPath segments (e.g., ["data", "id"])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Unary predicates that check a property without comparing to a value
|
|
25
|
+
*/
|
|
26
|
+
export enum UnaryPredicate {
|
|
27
|
+
IS_NULL = "IS_NULL",
|
|
28
|
+
IS_NOT_NULL = "IS_NOT_NULL",
|
|
29
|
+
IS_TRUE = "IS_TRUE",
|
|
30
|
+
IS_FALSE = "IS_FALSE",
|
|
31
|
+
IS_EMPTY = "IS_EMPTY",
|
|
32
|
+
IS_NOT_EMPTY = "IS_NOT_EMPTY",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Binary predicate operators that compare against an expected value
|
|
37
|
+
*/
|
|
38
|
+
export enum BinaryPredicateOperator {
|
|
39
|
+
EQUAL = "EQUAL",
|
|
40
|
+
NOT_EQUAL = "NOT_EQUAL",
|
|
41
|
+
GREATER_THAN = "GREATER_THAN",
|
|
42
|
+
LESS_THAN = "LESS_THAN",
|
|
43
|
+
GREATER_THAN_OR_EQUAL = "GREATER_THAN_OR_EQUAL",
|
|
44
|
+
LESS_THAN_OR_EQUAL = "LESS_THAN_OR_EQUAL",
|
|
45
|
+
CONTAINS = "CONTAINS",
|
|
46
|
+
NOT_CONTAINS = "NOT_CONTAINS",
|
|
47
|
+
STARTS_WITH = "STARTS_WITH",
|
|
48
|
+
NOT_STARTS_WITH = "NOT_STARTS_WITH",
|
|
49
|
+
ENDS_WITH = "ENDS_WITH",
|
|
50
|
+
NOT_ENDS_WITH = "NOT_ENDS_WITH",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binary predicate with operator and expected value
|
|
55
|
+
*/
|
|
56
|
+
export interface BinaryPredicate {
|
|
57
|
+
operator: BinaryPredicateOperator;
|
|
58
|
+
expected: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serialized assertion ready for execution
|
|
63
|
+
*/
|
|
64
|
+
export interface SerializedAssertion {
|
|
65
|
+
nodeId: string;
|
|
66
|
+
accessor: "body" | "headers" | "status";
|
|
67
|
+
path: string[];
|
|
68
|
+
predicate: UnaryPredicate | BinaryPredicate;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// State Proxy
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Symbol used to store path metadata in proxy objects
|
|
77
|
+
*/
|
|
78
|
+
const PATH_SYMBOL = Symbol("__path__");
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Internal interface for proxy objects that carry path information
|
|
82
|
+
*/
|
|
83
|
+
interface ProxyWithPath {
|
|
84
|
+
[PATH_SYMBOL]: PathDescriptor;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Proxy for accessing nested properties within a node result accessor (body, headers, status)
|
|
89
|
+
* Note: The 'at' method is available at runtime but typed as NestedProxy for simplicity
|
|
90
|
+
*/
|
|
91
|
+
export type NestedProxy = ProxyWithPath & {
|
|
92
|
+
[key: string]: NestedProxy;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Proxy for a node result with body, headers, and status accessors
|
|
97
|
+
*/
|
|
98
|
+
export type NodeResultProxy = {
|
|
99
|
+
body: NestedProxy;
|
|
100
|
+
headers: NestedProxy;
|
|
101
|
+
status: NestedProxy;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* State proxy that maps node names to their result proxies
|
|
106
|
+
*/
|
|
107
|
+
export type StateProxy<NodeNames extends string = string> = {
|
|
108
|
+
[K in NodeNames]: NodeResultProxy;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Creates a nested proxy that accumulates path segments
|
|
113
|
+
*/
|
|
114
|
+
function createNestedProxy(descriptor: PathDescriptor): NestedProxy {
|
|
115
|
+
return new Proxy({} as NestedProxy, {
|
|
116
|
+
get(_, prop: string | symbol) {
|
|
117
|
+
if (prop === PATH_SYMBOL) {
|
|
118
|
+
return descriptor;
|
|
119
|
+
}
|
|
120
|
+
if (prop === "at") {
|
|
121
|
+
return (index: number) => {
|
|
122
|
+
return createNestedProxy({
|
|
123
|
+
...descriptor,
|
|
124
|
+
path: [...descriptor.path, String(index)],
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Accumulate string property access
|
|
129
|
+
return createNestedProxy({
|
|
130
|
+
...descriptor,
|
|
131
|
+
path: [...descriptor.path, String(prop)],
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates a proxy for a node result with body, headers, and status accessors
|
|
139
|
+
*/
|
|
140
|
+
function createNodeResultProxy(nodeId: string): NodeResultProxy {
|
|
141
|
+
return {
|
|
142
|
+
body: createNestedProxy({ nodeId, accessor: "body", path: [] }),
|
|
143
|
+
headers: createNestedProxy({ nodeId, accessor: "headers", path: [] }),
|
|
144
|
+
status: createNestedProxy({ nodeId, accessor: "status", path: [] }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Creates a state proxy for the given node names
|
|
150
|
+
*/
|
|
151
|
+
export function createStateProxy<NodeNames extends string>(
|
|
152
|
+
nodeNames: NodeNames[],
|
|
153
|
+
): StateProxy<NodeNames> {
|
|
154
|
+
const proxy = new Proxy(
|
|
155
|
+
{},
|
|
156
|
+
{
|
|
157
|
+
get(_, nodeName: string | symbol) {
|
|
158
|
+
if (typeof nodeName === "symbol") {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return createNodeResultProxy(nodeName);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
return proxy as StateProxy<NodeNames>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// Assert Builder
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
export class AssertBuilder {
|
|
173
|
+
private negated = false;
|
|
174
|
+
|
|
175
|
+
constructor(private descriptor: PathDescriptor) {}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Negation modifier - flips the meaning of the subsequent predicate
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* Assert(state["node"].body["id"]).not.isNull() // IS_NOT_NULL
|
|
182
|
+
* Assert(state["node"].body["name"]).not.equals("") // NOT_EQUAL
|
|
183
|
+
*/
|
|
184
|
+
get not(): this {
|
|
185
|
+
const negatedBuilder = new AssertBuilder(this.descriptor);
|
|
186
|
+
negatedBuilder.negated = !this.negated;
|
|
187
|
+
return negatedBuilder as this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Unary predicates
|
|
191
|
+
|
|
192
|
+
isNull(): SerializedAssertion {
|
|
193
|
+
return this.createAssertion(
|
|
194
|
+
this.negated ? UnaryPredicate.IS_NOT_NULL : UnaryPredicate.IS_NULL,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
isDefined(): SerializedAssertion {
|
|
199
|
+
return this.createAssertion(
|
|
200
|
+
this.negated ? UnaryPredicate.IS_NULL : UnaryPredicate.IS_NOT_NULL,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
isTrue(): SerializedAssertion {
|
|
205
|
+
return this.createAssertion(
|
|
206
|
+
this.negated ? UnaryPredicate.IS_FALSE : UnaryPredicate.IS_TRUE,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
isFalse(): SerializedAssertion {
|
|
211
|
+
return this.createAssertion(
|
|
212
|
+
this.negated ? UnaryPredicate.IS_TRUE : UnaryPredicate.IS_FALSE,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
isEmpty(): SerializedAssertion {
|
|
217
|
+
return this.createAssertion(
|
|
218
|
+
this.negated ? UnaryPredicate.IS_NOT_EMPTY : UnaryPredicate.IS_EMPTY,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Binary predicates
|
|
223
|
+
|
|
224
|
+
equals(expected: unknown): SerializedAssertion {
|
|
225
|
+
return this.createAssertion({
|
|
226
|
+
operator: this.negated
|
|
227
|
+
? BinaryPredicateOperator.NOT_EQUAL
|
|
228
|
+
: BinaryPredicateOperator.EQUAL,
|
|
229
|
+
expected,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
greaterThan(expected: number): SerializedAssertion {
|
|
234
|
+
return this.createAssertion({
|
|
235
|
+
operator: this.negated
|
|
236
|
+
? BinaryPredicateOperator.LESS_THAN_OR_EQUAL
|
|
237
|
+
: BinaryPredicateOperator.GREATER_THAN,
|
|
238
|
+
expected,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
lessThan(expected: number): SerializedAssertion {
|
|
243
|
+
return this.createAssertion({
|
|
244
|
+
operator: this.negated
|
|
245
|
+
? BinaryPredicateOperator.GREATER_THAN_OR_EQUAL
|
|
246
|
+
: BinaryPredicateOperator.LESS_THAN,
|
|
247
|
+
expected,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
greaterThanOrEqual(expected: number): SerializedAssertion {
|
|
252
|
+
return this.createAssertion({
|
|
253
|
+
operator: this.negated
|
|
254
|
+
? BinaryPredicateOperator.LESS_THAN
|
|
255
|
+
: BinaryPredicateOperator.GREATER_THAN_OR_EQUAL,
|
|
256
|
+
expected,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
lessThanOrEqual(expected: number): SerializedAssertion {
|
|
261
|
+
return this.createAssertion({
|
|
262
|
+
operator: this.negated
|
|
263
|
+
? BinaryPredicateOperator.GREATER_THAN
|
|
264
|
+
: BinaryPredicateOperator.LESS_THAN_OR_EQUAL,
|
|
265
|
+
expected,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
contains(expected: string): SerializedAssertion {
|
|
270
|
+
return this.createAssertion({
|
|
271
|
+
operator: this.negated
|
|
272
|
+
? BinaryPredicateOperator.NOT_CONTAINS
|
|
273
|
+
: BinaryPredicateOperator.CONTAINS,
|
|
274
|
+
expected,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
startsWith(expected: string): SerializedAssertion {
|
|
279
|
+
return this.createAssertion({
|
|
280
|
+
operator: this.negated
|
|
281
|
+
? BinaryPredicateOperator.NOT_STARTS_WITH
|
|
282
|
+
: BinaryPredicateOperator.STARTS_WITH,
|
|
283
|
+
expected,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
endsWith(expected: string): SerializedAssertion {
|
|
288
|
+
return this.createAssertion({
|
|
289
|
+
operator: this.negated
|
|
290
|
+
? BinaryPredicateOperator.NOT_ENDS_WITH
|
|
291
|
+
: BinaryPredicateOperator.ENDS_WITH,
|
|
292
|
+
expected,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private createAssertion(
|
|
297
|
+
predicate: UnaryPredicate | BinaryPredicate,
|
|
298
|
+
): SerializedAssertion {
|
|
299
|
+
return {
|
|
300
|
+
nodeId: this.descriptor.nodeId,
|
|
301
|
+
accessor: this.descriptor.accessor,
|
|
302
|
+
path: this.descriptor.path,
|
|
303
|
+
predicate,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Creates an assertion builder from a state proxy reference
|
|
310
|
+
*
|
|
311
|
+
* @param proxyRef - A reference obtained from the state proxy (e.g., state["node"].body["id"])
|
|
312
|
+
* @returns An AssertBuilder for constructing the assertion
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* Assert(state["create_user"].body["data"]["id"]).not.isNull()
|
|
316
|
+
* Assert(state["create_user"].status).equals(201)
|
|
317
|
+
* Assert(state["create_user"].headers["content-type"]).contains("application/json")
|
|
318
|
+
*/
|
|
319
|
+
export function Assert(proxyRef: NestedProxy): AssertBuilder {
|
|
320
|
+
const descriptor = (proxyRef as ProxyWithPath)[PATH_SYMBOL];
|
|
321
|
+
if (!descriptor) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
"Assert() must be called with a reference from the state proxy",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return new AssertBuilder(descriptor);
|
|
327
|
+
}
|
package/src/builder.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TestPlanV1,
|
|
3
|
+
Node,
|
|
4
|
+
Edge,
|
|
5
|
+
Frequency,
|
|
6
|
+
HttpMethod,
|
|
7
|
+
ResponseFormat,
|
|
8
|
+
Endpoint,
|
|
9
|
+
NodeType,
|
|
10
|
+
Wait,
|
|
11
|
+
Assertions,
|
|
12
|
+
TEST_PLAN_VERSION,
|
|
13
|
+
JSONAssertion,
|
|
14
|
+
} from "./schema.js";
|
|
15
|
+
import { type START as StartType, type END as EndType } from "./constants";
|
|
16
|
+
|
|
17
|
+
type RawPlan = Omit<TestPlanV1, "id" | "environment">;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A node definition without the id field.
|
|
21
|
+
* The id is provided separately to addNode for cleaner separation of concerns.
|
|
22
|
+
*/
|
|
23
|
+
export type NodeWithoutId = Omit<Node, "id">;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* TestBuilder provides a type-safe DSL for constructing test plans with compile-time graph validation.
|
|
27
|
+
*
|
|
28
|
+
* Type parameters track the state of the graph during construction:
|
|
29
|
+
* @template NodeName - Union of all registered node names
|
|
30
|
+
* @template HasOutput - Union of nodes that have outgoing edges
|
|
31
|
+
* @template HasInput - Union of nodes that have incoming edges
|
|
32
|
+
*/
|
|
33
|
+
export interface TestBuilder<
|
|
34
|
+
NodeName extends string = never,
|
|
35
|
+
HasOutput extends string = never,
|
|
36
|
+
HasInput extends string = never,
|
|
37
|
+
> {
|
|
38
|
+
/**
|
|
39
|
+
* Adds a node to the test graph.
|
|
40
|
+
*
|
|
41
|
+
* @param name - Unique identifier for this node in the graph
|
|
42
|
+
* @param node - Node definition (Endpoint, WaitNode, Assertion)
|
|
43
|
+
* @returns Updated builder with the node registered in NodeName
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* builder.addNode("health", Endpoint({ method: GET, path: "/health", response_format: JSON }))
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
addNode<Name extends string>(
|
|
51
|
+
name: Name,
|
|
52
|
+
node: NodeWithoutId,
|
|
53
|
+
): TestBuilder<NodeName | Name, HasOutput, HasInput>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Adds a directed edge between two nodes in the graph.
|
|
57
|
+
*
|
|
58
|
+
* Compile-time constraints:
|
|
59
|
+
* - `from` must be START or a node that doesn't already have an outgoing edge
|
|
60
|
+
* - `to` must be END or a node that isn't the same as `from`
|
|
61
|
+
* - Both nodes must exist (be registered via addNode)
|
|
62
|
+
*
|
|
63
|
+
* @param from - Source node name or START constant
|
|
64
|
+
* @param to - Target node name or END constant
|
|
65
|
+
* @returns Updated builder with edge tracking updated
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* builder
|
|
70
|
+
* .addEdge(START, "health")
|
|
71
|
+
* .addEdge("health", "wait")
|
|
72
|
+
* .addEdge("wait", END)
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
addEdge<
|
|
76
|
+
From extends StartType | Exclude<NodeName, HasOutput>,
|
|
77
|
+
To extends EndType | Exclude<NodeName, From>,
|
|
78
|
+
>(
|
|
79
|
+
from: From,
|
|
80
|
+
to: To,
|
|
81
|
+
): TestBuilder<
|
|
82
|
+
NodeName,
|
|
83
|
+
From extends StartType ? HasOutput : HasOutput | From,
|
|
84
|
+
To extends EndType ? HasInput : HasInput | To
|
|
85
|
+
>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds the final test plan.
|
|
89
|
+
*
|
|
90
|
+
* This method is only callable when all graph constraints are satisfied:
|
|
91
|
+
* - All nodes must have at least one outgoing edge
|
|
92
|
+
* - All nodes must have at least one incoming edge
|
|
93
|
+
*
|
|
94
|
+
* If constraints aren't met, the return type becomes `never`, causing a compile error.
|
|
95
|
+
*/
|
|
96
|
+
build: [Exclude<NodeName, HasOutput>] extends [never]
|
|
97
|
+
? [Exclude<NodeName, HasInput>] extends [never]
|
|
98
|
+
? () => TestPlanV1
|
|
99
|
+
: {
|
|
100
|
+
error: "Some nodes have no incoming edges";
|
|
101
|
+
unconnected: Exclude<NodeName, HasInput>;
|
|
102
|
+
}
|
|
103
|
+
: {
|
|
104
|
+
error: "Some nodes have no outgoing edges";
|
|
105
|
+
unconnected: Exclude<NodeName, HasOutput>;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Internal implementation class for TestBuilder.
|
|
111
|
+
* Uses explicit type assertions at method boundaries to maintain phantom type tracking.
|
|
112
|
+
*/
|
|
113
|
+
class TestBuilderImpl<
|
|
114
|
+
NodeName extends string = never,
|
|
115
|
+
HasOutput extends string = never,
|
|
116
|
+
HasInput extends string = never,
|
|
117
|
+
> {
|
|
118
|
+
private nodes: Node[] = [];
|
|
119
|
+
private edges: Edge[] = [];
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
private config: {
|
|
123
|
+
name: string;
|
|
124
|
+
frequency?: Frequency;
|
|
125
|
+
locations?: string[];
|
|
126
|
+
},
|
|
127
|
+
) {}
|
|
128
|
+
|
|
129
|
+
addNode<Name extends string>(
|
|
130
|
+
name: Name,
|
|
131
|
+
node: NodeWithoutId,
|
|
132
|
+
): TestBuilder<NodeName | Name, HasOutput, HasInput> {
|
|
133
|
+
// Merge the name into the node to create a complete Node
|
|
134
|
+
// Type assertion required: spreading Omit<Node, 'id'> + id produces a valid Node at runtime
|
|
135
|
+
this.nodes.push({ ...node, id: name } as unknown as Node);
|
|
136
|
+
// Type assertion: we've added Name to NodeName union
|
|
137
|
+
return this as unknown as TestBuilder<NodeName | Name, HasOutput, HasInput>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
addEdge<
|
|
141
|
+
From extends StartType | Exclude<NodeName, HasOutput>,
|
|
142
|
+
To extends EndType | Exclude<NodeName, From>,
|
|
143
|
+
>(
|
|
144
|
+
from: From,
|
|
145
|
+
to: To,
|
|
146
|
+
): TestBuilder<
|
|
147
|
+
NodeName,
|
|
148
|
+
From extends StartType ? HasOutput : HasOutput | From,
|
|
149
|
+
To extends EndType ? HasInput : HasInput | To
|
|
150
|
+
> {
|
|
151
|
+
this.edges.push({ from, to });
|
|
152
|
+
// Type assertion: we've updated HasOutput and HasInput based on edge direction
|
|
153
|
+
return this as unknown as TestBuilder<
|
|
154
|
+
NodeName,
|
|
155
|
+
From extends StartType ? HasOutput : HasOutput | From,
|
|
156
|
+
To extends EndType ? HasInput : HasInput | To
|
|
157
|
+
>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get build(): TestBuilder<NodeName, HasOutput, HasInput>["build"] {
|
|
161
|
+
const { name, frequency, locations } = this.config;
|
|
162
|
+
const nodes = this.nodes;
|
|
163
|
+
const edges = this.edges;
|
|
164
|
+
|
|
165
|
+
const buildFn = (): RawPlan => {
|
|
166
|
+
return {
|
|
167
|
+
name,
|
|
168
|
+
version: TEST_PLAN_VERSION,
|
|
169
|
+
frequency,
|
|
170
|
+
locations,
|
|
171
|
+
nodes,
|
|
172
|
+
edges,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Type assertion: build is only callable when graph constraints are satisfied
|
|
177
|
+
// The conditional type in TestBuilder['build'] enforces this at the call site
|
|
178
|
+
return buildFn as TestBuilder<NodeName, HasOutput, HasInput>["build"];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Creates a new test builder for constructing a test plan.
|
|
184
|
+
*
|
|
185
|
+
* @param config - Configuration for the test plan
|
|
186
|
+
* @param config.name - Name of the test
|
|
187
|
+
* @param config.frequency - Optional frequency for scheduled execution
|
|
188
|
+
* @param config.locations - Optional array of location identifiers where this test should run
|
|
189
|
+
* @returns A new TestBuilder instance
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const plan = createGraphBuilder({
|
|
194
|
+
* name: "health-check",
|
|
195
|
+
* frequency: Frequency.every(5).minute(),
|
|
196
|
+
* locations: ["us-east-1", "eu-west-1"]
|
|
197
|
+
* })
|
|
198
|
+
* .addNode("health", Endpoint({ method: GET, path: "/health", response_format: JSON }))
|
|
199
|
+
* .addEdge(START, "health")
|
|
200
|
+
* .addEdge("health", END)
|
|
201
|
+
* .build();
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function createGraphBuilder(config: {
|
|
205
|
+
name: string;
|
|
206
|
+
frequency?: Frequency;
|
|
207
|
+
locations?: string[];
|
|
208
|
+
}): TestBuilder {
|
|
209
|
+
return new TestBuilderImpl(config);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// Node Factory Functions
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Configuration for an Endpoint node
|
|
218
|
+
* Accepts DSL-friendly literal types which are converted to schema enums internally
|
|
219
|
+
*/
|
|
220
|
+
export interface EndpointConfig {
|
|
221
|
+
method:
|
|
222
|
+
| "GET"
|
|
223
|
+
| "POST"
|
|
224
|
+
| "PUT"
|
|
225
|
+
| "DELETE"
|
|
226
|
+
| "PATCH"
|
|
227
|
+
| "HEAD"
|
|
228
|
+
| "OPTIONS"
|
|
229
|
+
| "CONNECT"
|
|
230
|
+
| "TRACE";
|
|
231
|
+
path: string;
|
|
232
|
+
base: Endpoint["base"];
|
|
233
|
+
response_format: "JSON" | "XML" | "TEXT";
|
|
234
|
+
headers?: Record<string, any>;
|
|
235
|
+
body?: any;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Creates an Endpoint node for making HTTP requests.
|
|
240
|
+
*
|
|
241
|
+
* @param config - Endpoint configuration (method, path, base, headers, etc.)
|
|
242
|
+
* @returns An Endpoint node (without id) ready to be added to a TestBuilder
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```typescript
|
|
246
|
+
* import { target } from './target';
|
|
247
|
+
*
|
|
248
|
+
* builder.addNode("health", Endpoint({
|
|
249
|
+
* method: GET,
|
|
250
|
+
* path: "/health",
|
|
251
|
+
* base: target("api-gateway"),
|
|
252
|
+
* response_format: JSON
|
|
253
|
+
* }));
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
export function Endpoint(config: EndpointConfig): Omit<Endpoint, "id"> {
|
|
257
|
+
return {
|
|
258
|
+
type: NodeType.ENDPOINT,
|
|
259
|
+
method: config.method as HttpMethod,
|
|
260
|
+
path: config.path,
|
|
261
|
+
base: config.base,
|
|
262
|
+
response_format: config.response_format as ResponseFormat,
|
|
263
|
+
headers: config.headers,
|
|
264
|
+
body: config.body,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Duration specification for Wait nodes.
|
|
270
|
+
* Accepts either milliseconds directly or an object with seconds/minutes.
|
|
271
|
+
*/
|
|
272
|
+
export type WaitDuration = number | { seconds: number } | { minutes: number };
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Converts a WaitDuration to milliseconds.
|
|
276
|
+
*/
|
|
277
|
+
function toMilliseconds(duration: WaitDuration): number {
|
|
278
|
+
if (typeof duration === "number") {
|
|
279
|
+
return duration;
|
|
280
|
+
}
|
|
281
|
+
if ("seconds" in duration) {
|
|
282
|
+
return duration.seconds * 1000;
|
|
283
|
+
}
|
|
284
|
+
if ("minutes" in duration) {
|
|
285
|
+
return duration.minutes * 60 * 1000;
|
|
286
|
+
}
|
|
287
|
+
throw new Error("Invalid duration format");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Creates a Wait node that pauses execution for a specified duration.
|
|
292
|
+
*
|
|
293
|
+
* @param duration - Duration to wait (milliseconds, or {seconds: n}, or {minutes: n})
|
|
294
|
+
* @returns A Wait node (without id) ready to be added to a TestBuilder
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* import { Wait, WaitDuration } from './index';
|
|
299
|
+
*
|
|
300
|
+
* builder.addNode("pause", Wait(WaitDuration.seconds(2)));
|
|
301
|
+
* // or with raw milliseconds:
|
|
302
|
+
* builder.addNode("pause", Wait(2000));
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export function Wait(duration: WaitDuration): Omit<Wait, "id"> {
|
|
306
|
+
return {
|
|
307
|
+
type: NodeType.WAIT,
|
|
308
|
+
duration_ms: toMilliseconds(duration),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Creates an Assertion node that validates test conditions.
|
|
314
|
+
*
|
|
315
|
+
* @param assertions - Array of SerializedAssertions to evaluate
|
|
316
|
+
* @returns An Assertion node (without id) ready to be added to a TestBuilder
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```typescript
|
|
320
|
+
* import { Assert } from './assertions';
|
|
321
|
+
*
|
|
322
|
+
* builder.addNode("checks", Assertion([
|
|
323
|
+
* Assert(state["node"].status).equals(200),
|
|
324
|
+
* Assert(state["node"].body["status"]).equals("ok")
|
|
325
|
+
* ]));
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
export function Assertion(assertions: JSONAssertion[]): Omit<Assertions, "id"> {
|
|
329
|
+
return {
|
|
330
|
+
type: NodeType.ASSERTION,
|
|
331
|
+
assertions: assertions.map((assertion) => ({
|
|
332
|
+
assertionType: ResponseFormat.JSON,
|
|
333
|
+
...assertion,
|
|
334
|
+
})),
|
|
335
|
+
};
|
|
336
|
+
}
|