@appthrust/kest 0.5.0 → 0.6.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
@@ -113,17 +113,14 @@ await ns.assert({
113
113
  Custom timeouts are supported per action:
114
114
 
115
115
  ```ts
116
- await ns.assert(
117
- {
118
- apiVersion: "apps/v1",
119
- kind: "Deployment",
120
- name: "my-app",
121
- test() {
122
- expect(this.status?.availableReplicas).toBe(3);
123
- },
116
+ await ns.assert({
117
+ apiVersion: "apps/v1",
118
+ kind: "Deployment",
119
+ name: "my-app",
120
+ test() {
121
+ expect(this.status?.availableReplicas).toBe(3);
124
122
  },
125
- { timeout: "30s", interval: "1s" },
126
- );
123
+ });
127
124
  ```
128
125
 
129
126
  ### Create Resources
@@ -252,7 +249,7 @@ await ns.assertOne<ConfigMap>({
252
249
  test() {
253
250
  expect(this.data?.mode).toBe("auto");
254
251
  },
255
- }, { timeout: "30s", interval: "1s" });
252
+ });
256
253
  ```
257
254
 
258
255
  `assertOne` throws if zero or more than one resource matches, and retries until exactly one is found or the timeout expires.
@@ -269,19 +266,57 @@ await ns.assertAbsence({
269
266
  });
270
267
  ```
271
268
 
272
- With retry-based polling to wait for a resource to disappear:
269
+ ### Error Assertions
270
+
271
+ Assert that applying or creating a resource produces an error (e.g. an admission webhook rejects the request, or a validation rule fails). The `test` callback inspects the error -- `this` is bound to the `Error`:
273
272
 
274
273
  ```ts
275
- await ns.assertAbsence(
276
- {
277
- apiVersion: "apps/v1",
278
- kind: "Deployment",
279
- name: "my-app",
274
+ await ns.assertApplyError({
275
+ apply: {
276
+ apiVersion: "example.com/v1",
277
+ kind: "MyResource",
278
+ metadata: { name: "my-resource" },
279
+ spec: { immutableField: "changed" },
280
+ },
281
+ test() {
282
+ expect(this.message).toContain("field is immutable");
280
283
  },
281
- { timeout: "30s", interval: "1s" },
282
- );
284
+ });
285
+ ```
286
+
287
+ The `test` callback participates in retry -- if it throws, the action is retried until the callback passes or the timeout expires. This is useful when a webhook is being set up asynchronously:
288
+
289
+ ```ts
290
+ await ns.assertApplyError({
291
+ apply: {
292
+ apiVersion: "example.com/v1",
293
+ kind: "MyResource",
294
+ metadata: { name: "my-resource" },
295
+ spec: { immutableField: "changed" },
296
+ },
297
+ test(error) {
298
+ expect(error.message).toContain("field is immutable");
299
+ },
300
+ });
283
301
  ```
284
302
 
303
+ `assertCreateError` works identically for `kubectl create`:
304
+
305
+ ```ts
306
+ await ns.assertCreateError({
307
+ create: {
308
+ apiVersion: "v1",
309
+ kind: "ConfigMap",
310
+ metadata: { name: "already-exists" },
311
+ },
312
+ test(error) {
313
+ expect(error.message).toContain("already exists");
314
+ },
315
+ });
316
+ ```
317
+
318
+ If the apply/create unexpectedly succeeds (e.g. the webhook is not yet active), the resource is immediately reverted and the action retries until the expected error occurs.
319
+
285
320
  ### Label Resources
286
321
 
287
322
  Add, update, or remove labels on Kubernetes resources using `kubectl label`:
@@ -470,6 +505,8 @@ The top-level API surface available in every test callback.
470
505
  | ----------------------------------------------------------------------- | ----------------------------------------------------------- |
471
506
  | `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
472
507
  | `create(manifest, options?)` | Create a Kubernetes resource and register cleanup |
508
+ | `assertApplyError(input, options?)` | Assert that `kubectl apply` produces an error |
509
+ | `assertCreateError(input, options?)` | Assert that `kubectl create` produces an error |
473
510
  | `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
474
511
  | `delete(resource, options?)` | Delete a resource by API version, kind, and name |
475
512
  | `label(input, options?)` | Add, update, or remove labels on a resource |
@@ -486,7 +523,7 @@ The top-level API surface available in every test callback.
486
523
 
487
524
  ### Namespace / Cluster
488
525
 
489
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
526
+ Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `assertApplyError`, `assertCreateError`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
490
527
 
491
528
  `Namespace` also exposes a `name` property:
492
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -0,0 +1,43 @@
1
+ import type { AssertApplyErrorInput } from "../apis";
2
+ import { apply } from "./apply";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const assertApplyError = {
6
+ type: "mutate",
7
+ name: "AssertApplyError",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async (input) => {
11
+ const applyFn = apply.mutate({ kubectl });
12
+ let result: Awaited<ReturnType<typeof applyFn>> | undefined;
13
+ let rejection: Error | undefined;
14
+ try {
15
+ result = await applyFn(input.apply);
16
+ } catch (err) {
17
+ rejection = err as Error;
18
+ }
19
+
20
+ // Apply succeeded unexpectedly -- revert immediately and throw so that
21
+ // the scenario wrapper retries.
22
+ if (result !== undefined) {
23
+ await result.revert();
24
+ throw new Error(
25
+ `Expected ${apply.describe(input.apply)} to err, but it succeeded`
26
+ );
27
+ }
28
+
29
+ // Apply erred as expected -- run test callback.
30
+ // biome-ignore lint/style/noNonNullAssertion: rejection is guaranteed non-undefined when result is undefined
31
+ await input.test.call(rejection!, rejection!);
32
+
33
+ return {
34
+ async revert() {
35
+ // Nothing to clean up -- the resource was never created.
36
+ },
37
+ output: undefined,
38
+ };
39
+ },
40
+ describe: (input) => {
41
+ return `${apply.describe(input.apply)} (expected error)`;
42
+ },
43
+ } satisfies MutateDef<AssertApplyErrorInput, void>;
@@ -0,0 +1,43 @@
1
+ import type { AssertCreateErrorInput } from "../apis";
2
+ import { create } from "./create";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const assertCreateError = {
6
+ type: "mutate",
7
+ name: "AssertCreateError",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async (input) => {
11
+ const createFn = create.mutate({ kubectl });
12
+ let result: Awaited<ReturnType<typeof createFn>> | undefined;
13
+ let rejection: Error | undefined;
14
+ try {
15
+ result = await createFn(input.create);
16
+ } catch (err) {
17
+ rejection = err as Error;
18
+ }
19
+
20
+ // Create succeeded unexpectedly -- revert immediately and throw so that
21
+ // the scenario wrapper retries.
22
+ if (result !== undefined) {
23
+ await result.revert();
24
+ throw new Error(
25
+ `Expected ${create.describe(input.create)} to err, but it succeeded`
26
+ );
27
+ }
28
+
29
+ // Create erred as expected -- run test callback.
30
+ // biome-ignore lint/style/noNonNullAssertion: rejection is guaranteed non-undefined when result is undefined
31
+ await input.test.call(rejection!, rejection!);
32
+
33
+ return {
34
+ async revert() {
35
+ // Nothing to clean up -- the resource was never created.
36
+ },
37
+ output: undefined,
38
+ };
39
+ },
40
+ describe: (input) => {
41
+ return `${create.describe(input.create)} (expected error)`;
42
+ },
43
+ } satisfies MutateDef<AssertCreateErrorInput, void>;
@@ -1,6 +1,6 @@
1
+ import { generateName } from "../naming";
1
2
  import { create } from "./create";
2
3
  import type { MutateDef } from "./types";
3
- import { generateName } from "../naming";
4
4
 
5
5
  /**
6
6
  * Input for namespace creation.
package/ts/apis/index.ts CHANGED
@@ -87,6 +87,75 @@ export interface Scenario {
87
87
  options?: undefined | ActionOptions
88
88
  ): Promise<void>;
89
89
 
90
+ /**
91
+ * Asserts that `kubectl apply` produces an error.
92
+ *
93
+ * The manifest is applied, and the action succeeds when the API server
94
+ * returns an error (e.g. an admission webhook rejects the request). The
95
+ * `test` callback must also pass for the action to succeed.
96
+ *
97
+ * If the apply unexpectedly succeeds, the created resource is immediately
98
+ * reverted and the action is retried until the expected error occurs or the
99
+ * timeout expires.
100
+ *
101
+ * @template T - The expected Kubernetes resource shape.
102
+ * @param input - Manifest to apply and error assertion callback.
103
+ * @param options - Retry options such as timeout and polling interval.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * await s.assertApplyError({
108
+ * apply: {
109
+ * apiVersion: "example.com/v1",
110
+ * kind: "MyResource",
111
+ * metadata: { name: "my-resource" },
112
+ * spec: { immutableField: "changed" },
113
+ * },
114
+ * test() {
115
+ * expect(this.message).toContain("field is immutable");
116
+ * },
117
+ * });
118
+ * ```
119
+ */
120
+ assertApplyError<T extends K8sResource>(
121
+ input: AssertApplyErrorInput<T>,
122
+ options?: undefined | ActionOptions
123
+ ): Promise<void>;
124
+
125
+ /**
126
+ * Asserts that `kubectl create` produces an error.
127
+ *
128
+ * The manifest is created, and the action succeeds when the API server
129
+ * returns an error. The `test` callback must also pass for the action to
130
+ * succeed.
131
+ *
132
+ * If the create unexpectedly succeeds, the created resource is immediately
133
+ * reverted and the action is retried until the expected error occurs or the
134
+ * timeout expires.
135
+ *
136
+ * @template T - The expected Kubernetes resource shape.
137
+ * @param input - Manifest to create and error assertion callback.
138
+ * @param options - Retry options such as timeout and polling interval.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * await s.assertCreateError({
143
+ * create: {
144
+ * apiVersion: "v1",
145
+ * kind: "ConfigMap",
146
+ * metadata: { name: "already-exists" },
147
+ * },
148
+ * test(error) {
149
+ * expect(error.message).toContain("already exists");
150
+ * },
151
+ * });
152
+ * ```
153
+ */
154
+ assertCreateError<T extends K8sResource>(
155
+ input: AssertCreateErrorInput<T>,
156
+ options?: undefined | ActionOptions
157
+ ): Promise<void>;
158
+
90
159
  /**
91
160
  * Applies the `status` subresource using server-side apply.
92
161
  *
@@ -573,6 +642,30 @@ export interface Cluster {
573
642
  options?: undefined | ActionOptions
574
643
  ): Promise<void>;
575
644
 
645
+ /**
646
+ * Asserts that `kubectl apply` produces an error.
647
+ *
648
+ * @template T - The expected Kubernetes resource shape.
649
+ * @param input - Manifest to apply and error assertion callback.
650
+ * @param options - Retry options such as timeout and polling interval.
651
+ */
652
+ assertApplyError<T extends K8sResource>(
653
+ input: AssertApplyErrorInput<T>,
654
+ options?: undefined | ActionOptions
655
+ ): Promise<void>;
656
+
657
+ /**
658
+ * Asserts that `kubectl create` produces an error.
659
+ *
660
+ * @template T - The expected Kubernetes resource shape.
661
+ * @param input - Manifest to create and error assertion callback.
662
+ * @param options - Retry options such as timeout and polling interval.
663
+ */
664
+ assertCreateError<T extends K8sResource>(
665
+ input: AssertCreateErrorInput<T>,
666
+ options?: undefined | ActionOptions
667
+ ): Promise<void>;
668
+
576
669
  /**
577
670
  * Applies the `status` subresource using server-side apply.
578
671
  *
@@ -871,6 +964,30 @@ export interface Namespace {
871
964
  options?: undefined | ActionOptions
872
965
  ): Promise<void>;
873
966
 
967
+ /**
968
+ * Asserts that `kubectl apply` produces an error in this namespace.
969
+ *
970
+ * @template T - The expected Kubernetes resource shape.
971
+ * @param input - Manifest to apply and error assertion callback.
972
+ * @param options - Retry options such as timeout and polling interval.
973
+ */
974
+ assertApplyError<T extends K8sResource>(
975
+ input: AssertApplyErrorInput<T>,
976
+ options?: undefined | ActionOptions
977
+ ): Promise<void>;
978
+
979
+ /**
980
+ * Asserts that `kubectl create` produces an error in this namespace.
981
+ *
982
+ * @template T - The expected Kubernetes resource shape.
983
+ * @param input - Manifest to create and error assertion callback.
984
+ * @param options - Retry options such as timeout and polling interval.
985
+ */
986
+ assertCreateError<T extends K8sResource>(
987
+ input: AssertCreateErrorInput<T>,
988
+ options?: undefined | ActionOptions
989
+ ): Promise<void>;
990
+
874
991
  /**
875
992
  * Applies the `status` subresource in this namespace using server-side apply.
876
993
  *
@@ -1279,6 +1396,56 @@ export interface ResourceOneTest<T extends K8sResource = K8sResource> {
1279
1396
  readonly test: (this: T, resource: T) => unknown | Promise<unknown>;
1280
1397
  }
1281
1398
 
1399
+ /**
1400
+ * A test definition for {@link Scenario.assertApplyError},
1401
+ * {@link Cluster.assertApplyError}, and {@link Namespace.assertApplyError}.
1402
+ *
1403
+ * Attempts `kubectl apply` and asserts that the API server returns an error.
1404
+ * When the operation errors as expected, the `test` callback is invoked with
1405
+ * `this` bound to the {@link Error}.
1406
+ */
1407
+ export interface AssertApplyErrorInput<T extends K8sResource = K8sResource> {
1408
+ /**
1409
+ * The manifest to apply. Accepts the same formats as {@link Scenario.apply}:
1410
+ * an object literal, a YAML string, or an imported YAML module.
1411
+ */
1412
+ readonly apply: ApplyingManifest<T>;
1413
+
1414
+ /**
1415
+ * Assertion callback invoked when the apply errors as expected.
1416
+ *
1417
+ * `this` is bound to the {@link Error} returned by the API server.
1418
+ * Throwing (or rejecting) signals that the error did not match expectations
1419
+ * and triggers a retry (if timeout allows).
1420
+ */
1421
+ readonly test: (this: Error, error: Error) => unknown | Promise<unknown>;
1422
+ }
1423
+
1424
+ /**
1425
+ * A test definition for {@link Scenario.assertCreateError},
1426
+ * {@link Cluster.assertCreateError}, and {@link Namespace.assertCreateError}.
1427
+ *
1428
+ * Attempts `kubectl create` and asserts that the API server returns an error.
1429
+ * When the operation errors as expected, the `test` callback is invoked with
1430
+ * `this` bound to the {@link Error}.
1431
+ */
1432
+ export interface AssertCreateErrorInput<T extends K8sResource = K8sResource> {
1433
+ /**
1434
+ * The manifest to create. Accepts the same formats as {@link Scenario.create}:
1435
+ * an object literal, a YAML string, or an imported YAML module.
1436
+ */
1437
+ readonly create: ApplyingManifest<T>;
1438
+
1439
+ /**
1440
+ * Assertion callback invoked when the create errors as expected.
1441
+ *
1442
+ * `this` is bound to the {@link Error} returned by the API server.
1443
+ * Throwing (or rejecting) signals that the error did not match expectations
1444
+ * and triggers a retry (if timeout allows).
1445
+ */
1446
+ readonly test: (this: Error, error: Error) => unknown | Promise<unknown>;
1447
+ }
1448
+
1282
1449
  /**
1283
1450
  * Kubernetes cluster selector for {@link Scenario.useCluster}.
1284
1451
  */
@@ -8,7 +8,8 @@ const consonantDigits = "bcdfghjklmnpqrstvwxyz0123456789";
8
8
  export function randomConsonantDigits(length = 8): string {
9
9
  let result = "";
10
10
  for (let i = 0; i < length; i++) {
11
- result += consonantDigits[Math.floor(Math.random() * consonantDigits.length)];
11
+ result +=
12
+ consonantDigits[Math.floor(Math.random() * consonantDigits.length)];
12
13
  }
13
14
  return result;
14
15
  }
@@ -24,4 +25,3 @@ export function randomConsonantDigits(length = 8): string {
24
25
  export function generateName(prefix: string, suffixLength = 5): string {
25
26
  return `${prefix}${randomConsonantDigits(suffixLength)}`;
26
27
  }
27
-
@@ -27,7 +27,7 @@ export interface BDDSection {
27
27
  export interface Action {
28
28
  name: string;
29
29
  attempts?: undefined | number;
30
- command?: undefined | Command;
30
+ commands: Array<Command>;
31
31
  error?: undefined | Error;
32
32
  }
33
33
 
@@ -114,7 +114,7 @@ function handleActionStart(
114
114
  return;
115
115
  }
116
116
 
117
- const action: Action = { name: event.data.description };
117
+ const action: Action = { name: event.data.description, commands: [] };
118
118
  scenario.overview.push({
119
119
  name: event.data.description,
120
120
  status: "pending",
@@ -180,17 +180,21 @@ function handleCommandResult(
180
180
  }
181
181
 
182
182
  const { currentAction } = state;
183
- if (!currentAction?.command) {
183
+ if (!currentAction || currentAction.commands.length === 0) {
184
184
  return;
185
185
  }
186
186
 
187
- currentAction.command.stdout = {
187
+ const command = currentAction.commands.at(-1);
188
+ if (!command) {
189
+ return;
190
+ }
191
+ command.stdout = {
188
192
  text: event.data.stdout,
189
193
  ...(event.data.stdoutLanguage
190
194
  ? { language: event.data.stdoutLanguage }
191
195
  : {}),
192
196
  };
193
- currentAction.command.stderr = {
197
+ command.stderr = {
194
198
  text: event.data.stderr,
195
199
  ...(event.data.stderrLanguage
196
200
  ? { language: event.data.stderrLanguage }
@@ -216,7 +220,7 @@ function handleCommandRun(
216
220
  if (!state.currentAction) {
217
221
  return;
218
222
  }
219
- state.currentAction.command = createCommandFromRun(event);
223
+ state.currentAction.commands.push(createCommandFromRun(event));
220
224
  }
221
225
 
222
226
  function handleScenarioStart(
@@ -165,7 +165,7 @@ export function renderReport(
165
165
  if (!status) {
166
166
  if (action.error) {
167
167
  status = "failure";
168
- } else if (action.command) {
168
+ } else if (action.commands.length > 0) {
169
169
  status = "success";
170
170
  } else {
171
171
  status = "pending";
@@ -180,8 +180,7 @@ export function renderReport(
180
180
  lines.push(`**${emoji} ${stripAnsi(action.name)}**${attemptsSuffix}`);
181
181
  lines.push("");
182
182
 
183
- const cmd = action.command;
184
- if (cmd) {
183
+ for (const cmd of action.commands) {
185
184
  const base = [cmd.cmd, ...cmd.args].join(" ").trim();
186
185
  const stdin = cmd.stdin?.text;
187
186
  const stdinLanguage = cmd.stdin?.language ?? "text";
@@ -2,6 +2,8 @@ import { apply } from "../actions/apply";
2
2
  import { applyStatus } from "../actions/apply-status";
3
3
  import { assert } from "../actions/assert";
4
4
  import { assertAbsence } from "../actions/assert-absence";
5
+ import { assertApplyError } from "../actions/assert-apply-error";
6
+ import { assertCreateError } from "../actions/assert-create-error";
5
7
  import { assertList } from "../actions/assert-list";
6
8
  import { assertOne } from "../actions/assert-one";
7
9
  import { create } from "../actions/create";
@@ -39,6 +41,8 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
39
41
  return {
40
42
  apply: createMutateFn(deps, apply),
41
43
  create: createMutateFn(deps, create),
44
+ assertApplyError: createMutateFn(deps, assertApplyError),
45
+ assertCreateError: createMutateFn(deps, assertCreateError),
42
46
  applyStatus: createOneWayMutateFn(deps, applyStatus),
43
47
  delete: createOneWayMutateFn(deps, deleteResource),
44
48
  label: createOneWayMutateFn(deps, label),
@@ -205,6 +209,8 @@ const createNewNamespaceFn =
205
209
  name: namespaceName,
206
210
  apply: createMutateFn(namespacedDeps, apply),
207
211
  create: createMutateFn(namespacedDeps, create),
212
+ assertApplyError: createMutateFn(namespacedDeps, assertApplyError),
213
+ assertCreateError: createMutateFn(namespacedDeps, assertCreateError),
208
214
  applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
209
215
  delete: createOneWayMutateFn(namespacedDeps, deleteResource),
210
216
  label: createOneWayMutateFn(namespacedDeps, label),
@@ -229,6 +235,8 @@ const createUseClusterFn =
229
235
  return {
230
236
  apply: createMutateFn(clusterDeps, apply),
231
237
  create: createMutateFn(clusterDeps, create),
238
+ assertApplyError: createMutateFn(clusterDeps, assertApplyError),
239
+ assertCreateError: createMutateFn(clusterDeps, assertCreateError),
232
240
  applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
233
241
  delete: createOneWayMutateFn(clusterDeps, deleteResource),
234
242
  label: createOneWayMutateFn(clusterDeps, label),