@appthrust/kest 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/LICENSE +21 -0
- package/README.md +412 -0
- package/example/config-map.yaml +6 -0
- package/example/example-report.md +744 -0
- package/example/example.test.ts +225 -0
- package/example/hello-world-crd.yaml +145 -0
- package/package.json +62 -0
- package/ts/actions/apply-namespace.ts +29 -0
- package/ts/actions/apply-status.ts +23 -0
- package/ts/actions/apply.ts +25 -0
- package/ts/actions/assert-list.ts +63 -0
- package/ts/actions/assert.ts +34 -0
- package/ts/actions/exec.ts +21 -0
- package/ts/actions/get.ts +40 -0
- package/ts/actions/types.ts +48 -0
- package/ts/apis/index.ts +788 -0
- package/ts/bdd/index.ts +30 -0
- package/ts/duration/index.ts +171 -0
- package/ts/index.ts +3 -0
- package/ts/k8s-resource/index.ts +120 -0
- package/ts/kubectl/index.ts +351 -0
- package/ts/recording/index.ts +134 -0
- package/ts/reporter/index.ts +0 -0
- package/ts/reporter/interface.ts +5 -0
- package/ts/reporter/markdown.ts +962 -0
- package/ts/retry.ts +112 -0
- package/ts/reverting/index.ts +36 -0
- package/ts/scenario/index.ts +220 -0
- package/ts/test.ts +127 -0
- package/ts/workspace/find-up.ts +20 -0
- package/ts/workspace/index.ts +25 -0
- package/ts/yaml/index.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 appthrust
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Kest
|
|
2
|
+
|
|
3
|
+
> **Preview Release** -- Kest is currently in `0.x` preview. The API may change based on feedback. Breaking changes can occur in any `0.x` release. A stable `1.0.0` will be released once the API solidifies. Feel free to [open an issue](https://github.com/appthrust/kest/issues/new) if you have any feedback.
|
|
4
|
+
|
|
5
|
+
**TypeScript-first E2E testing framework for Kubernetes**
|
|
6
|
+
|
|
7
|
+
Kest makes it easy to write reliable end-to-end tests for Kubernetes controllers, operators, and admission webhooks. You write test scenarios in TypeScript with full type safety, autocompletion, and the familiar `expect()` API.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { expect } from "bun:test";
|
|
11
|
+
import { test } from "@appthrust/kest";
|
|
12
|
+
|
|
13
|
+
test("Deployment creates expected ReplicaSet", async (s) => {
|
|
14
|
+
s.given("a namespace exists");
|
|
15
|
+
const ns = await s.newNamespace();
|
|
16
|
+
|
|
17
|
+
s.when("I apply a Deployment");
|
|
18
|
+
await ns.apply({
|
|
19
|
+
apiVersion: "apps/v1",
|
|
20
|
+
kind: "Deployment",
|
|
21
|
+
metadata: { name: "my-app" },
|
|
22
|
+
spec: {
|
|
23
|
+
replicas: 2,
|
|
24
|
+
selector: { matchLabels: { app: "my-app" } },
|
|
25
|
+
template: {
|
|
26
|
+
metadata: { labels: { app: "my-app" } },
|
|
27
|
+
spec: { containers: [{ name: "app", image: "nginx" }] },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
s.then("the Deployment should be available");
|
|
33
|
+
await ns.assert({
|
|
34
|
+
apiVersion: "apps/v1",
|
|
35
|
+
kind: "Deployment",
|
|
36
|
+
name: "my-app",
|
|
37
|
+
test() {
|
|
38
|
+
expect(this.status?.availableReplicas).toBe(2);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
// Cleanup is automatic: resources are deleted in reverse order,
|
|
42
|
+
// then the namespace is removed.
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Why TypeScript?
|
|
47
|
+
|
|
48
|
+
YAML and Go are the norm in the Kubernetes ecosystem, so why TypeScript?
|
|
49
|
+
|
|
50
|
+
**Why not YAML?** E2E tests are inherently procedural -- apply resources, wait for reconciliation, assert state, clean up. YAML is a data format, not a programming language, and becomes clunky when you try to express these sequential workflows directly.
|
|
51
|
+
|
|
52
|
+
**Why not Go?** Go has an excellent Kubernetes client ecosystem, but TypeScript object literals are far more concise than Go structs for expressing Kubernetes manifests inline. Tests read closer to the YAML you already know, without the boilerplate of typed struct initialization and pointer helpers.
|
|
53
|
+
|
|
54
|
+
**What TypeScript brings:**
|
|
55
|
+
|
|
56
|
+
- **Editor support** -- autocompletion, inline type checking, go-to-definition
|
|
57
|
+
- **Readability** -- object literals map naturally to Kubernetes manifests
|
|
58
|
+
- **Flexibility** -- loops, conditionals, helper functions, and shared fixtures are just code
|
|
59
|
+
- **Ecosystem** -- use any npm package for setup, assertions, or data generation
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
### Ephemeral Namespaces
|
|
64
|
+
|
|
65
|
+
Each test gets an isolated, auto-generated namespace (e.g. `kest-a1b2c`). Resources are confined to this namespace, eliminating interference between tests and enabling safe parallel execution. The namespace is deleted when the test ends.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const ns = await s.newNamespace();
|
|
69
|
+
// All resources applied through `ns` are scoped to this namespace.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Automatic Cleanup (Reverse-Order, Blocking)
|
|
73
|
+
|
|
74
|
+
Resources are deleted in the reverse order they were created (LIFO). Kest waits until each resource is fully removed before proceeding, preventing flaky failures caused by lingering resources or `Terminating` namespaces.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Created: Namespace → ConfigMap → Deployment → Service
|
|
78
|
+
Cleaned: Service → Deployment → ConfigMap → Namespace
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Retry-Based Assertions
|
|
82
|
+
|
|
83
|
+
Kubernetes is eventually consistent. Kest retries assertions automatically until they pass or a timeout expires, so you don't need fragile `sleep()` calls.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await ns.assert({
|
|
87
|
+
apiVersion: "v1",
|
|
88
|
+
kind: "ConfigMap",
|
|
89
|
+
name: "my-config",
|
|
90
|
+
test() {
|
|
91
|
+
// Retried until this passes (default: 5s timeout, 200ms interval)
|
|
92
|
+
expect(this.data?.mode).toBe("production");
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Custom timeouts are supported per action:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
await ns.assert(
|
|
101
|
+
{
|
|
102
|
+
apiVersion: "apps/v1",
|
|
103
|
+
kind: "Deployment",
|
|
104
|
+
name: "my-app",
|
|
105
|
+
test() {
|
|
106
|
+
expect(this.status?.availableReplicas).toBe(3);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{ timeout: "30s", interval: "1s" },
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Multiple Manifest Formats
|
|
114
|
+
|
|
115
|
+
Apply resources using whichever format is most convenient:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// Inline YAML string
|
|
119
|
+
await ns.apply(`
|
|
120
|
+
apiVersion: v1
|
|
121
|
+
kind: ConfigMap
|
|
122
|
+
metadata:
|
|
123
|
+
name: my-config
|
|
124
|
+
data:
|
|
125
|
+
mode: demo
|
|
126
|
+
`);
|
|
127
|
+
|
|
128
|
+
// TypeScript object literal (with type checking)
|
|
129
|
+
await ns.apply<ConfigMap>({
|
|
130
|
+
apiVersion: "v1",
|
|
131
|
+
kind: "ConfigMap",
|
|
132
|
+
metadata: { name: "my-config" },
|
|
133
|
+
data: { mode: "demo" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Imported YAML file
|
|
137
|
+
await ns.apply(import("./manifests/config-map.yaml"));
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Multi-Cluster Support
|
|
141
|
+
|
|
142
|
+
Test scenarios that span multiple clusters:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
test("resources sync across clusters", async (s) => {
|
|
146
|
+
const primary = await s.useCluster({ context: "kind-primary" });
|
|
147
|
+
const secondary = await s.useCluster({
|
|
148
|
+
context: "kind-secondary",
|
|
149
|
+
kubeconfig: ".kubeconfig.yaml",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const ns1 = await primary.newNamespace();
|
|
153
|
+
const ns2 = await secondary.newNamespace();
|
|
154
|
+
|
|
155
|
+
await ns1.apply(/* ... */);
|
|
156
|
+
await ns2.assert(/* ... */);
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Status Subresource Support
|
|
161
|
+
|
|
162
|
+
Simulate controller behavior by applying status subresources via server-side apply:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await ns.applyStatus({
|
|
166
|
+
apiVersion: "example.com/v1",
|
|
167
|
+
kind: "MyResource",
|
|
168
|
+
metadata: { name: "my-resource" },
|
|
169
|
+
status: {
|
|
170
|
+
conditions: [
|
|
171
|
+
{
|
|
172
|
+
type: "Ready",
|
|
173
|
+
status: "True",
|
|
174
|
+
lastTransitionTime: "2026-01-01T00:00:00Z",
|
|
175
|
+
reason: "Reconciled",
|
|
176
|
+
message: "Resource is ready.",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### List Assertions
|
|
184
|
+
|
|
185
|
+
Assert against a collection of resources:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
await ns.assertList<ConfigMap>({
|
|
189
|
+
apiVersion: "v1",
|
|
190
|
+
kind: "ConfigMap",
|
|
191
|
+
test() {
|
|
192
|
+
expect(this.some((c) => c.metadata.name === "my-config")).toBe(true);
|
|
193
|
+
expect(this.some((c) => c.metadata.name === "deleted-config")).toBe(false);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Shell Command Execution
|
|
199
|
+
|
|
200
|
+
Run arbitrary shell commands with optional revert handlers for cleanup:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const name = await s.exec({
|
|
204
|
+
do: async ({ $ }) => {
|
|
205
|
+
const name = "my-secret";
|
|
206
|
+
await $`kubectl create secret generic ${name} --from-literal=password=s3cr3t`.quiet();
|
|
207
|
+
return name;
|
|
208
|
+
},
|
|
209
|
+
revert: async ({ $ }) => {
|
|
210
|
+
await $`kubectl delete secret my-secret`.quiet();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### BDD-Style Reporting
|
|
216
|
+
|
|
217
|
+
Structure tests with Given/When/Then annotations for readable output:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
test("ConfigMap lifecycle", async (s) => {
|
|
221
|
+
s.given("a namespace exists");
|
|
222
|
+
const ns = await s.newNamespace();
|
|
223
|
+
|
|
224
|
+
s.when("I apply a ConfigMap");
|
|
225
|
+
await ns.apply(/* ... */);
|
|
226
|
+
|
|
227
|
+
s.then("the ConfigMap should have the expected data");
|
|
228
|
+
await ns.assert(/* ... */);
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Markdown Test Reports
|
|
233
|
+
|
|
234
|
+
When a test fails (or when `KEST_SHOW_REPORT=1` is set), Kest generates a detailed Markdown report showing every action, the exact `kubectl` commands executed, stdout/stderr output, and cleanup results. This provides full transparency into what happened during the test, making troubleshooting straightforward -- for both humans and AI assistants.
|
|
235
|
+
|
|
236
|
+
```markdown
|
|
237
|
+
# ConfigMap lifecycle
|
|
238
|
+
|
|
239
|
+
## Scenario Overview
|
|
240
|
+
|
|
241
|
+
| # | Action | Resource | Status |
|
|
242
|
+
| --- | ---------------- | ------------------- | ------ |
|
|
243
|
+
| 1 | Create namespace | kest-9hdhj | ✅ |
|
|
244
|
+
| 2 | Apply | ConfigMap/my-config | ✅ |
|
|
245
|
+
| 3 | Assert | ConfigMap/my-config | ✅ |
|
|
246
|
+
|
|
247
|
+
## Scenario Details
|
|
248
|
+
|
|
249
|
+
### Given: a namespace exists
|
|
250
|
+
|
|
251
|
+
✅ Create Namespace "kest-9hdhj"
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
### Cleanup
|
|
255
|
+
|
|
256
|
+
| # | Action | Resource | Status |
|
|
257
|
+
| --- | ---------------- | ------------------- | ------ |
|
|
258
|
+
| 1 | Delete | ConfigMap/my-config | ✅ |
|
|
259
|
+
| 2 | Delete namespace | kest-9hdhj | ✅ |
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Getting Started
|
|
263
|
+
|
|
264
|
+
### Prerequisites
|
|
265
|
+
|
|
266
|
+
- [Bun](https://bun.sh/) v1.3.8 or later
|
|
267
|
+
- `kubectl` configured with access to a Kubernetes cluster
|
|
268
|
+
- A running Kubernetes cluster (e.g. [kind](https://kind.sigs.k8s.io/), [minikube](https://minikube.sigs.k8s.io/), or a remote cluster)
|
|
269
|
+
|
|
270
|
+
### Installation
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
bun add -d @appthrust/kest
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Write Your First Test
|
|
277
|
+
|
|
278
|
+
Create a test file, e.g. `my-operator.test.ts`:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { expect } from "bun:test";
|
|
282
|
+
import { test } from "@appthrust/kest";
|
|
283
|
+
|
|
284
|
+
test("ConfigMap is created with correct data", async (s) => {
|
|
285
|
+
s.given("a new namespace exists");
|
|
286
|
+
const ns = await s.newNamespace();
|
|
287
|
+
|
|
288
|
+
s.when("I apply a ConfigMap");
|
|
289
|
+
await ns.apply({
|
|
290
|
+
apiVersion: "v1",
|
|
291
|
+
kind: "ConfigMap",
|
|
292
|
+
metadata: { name: "app-config" },
|
|
293
|
+
data: { environment: "test" },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
s.then("the ConfigMap should contain the expected data");
|
|
297
|
+
await ns.assert({
|
|
298
|
+
apiVersion: "v1",
|
|
299
|
+
kind: "ConfigMap",
|
|
300
|
+
name: "app-config",
|
|
301
|
+
test() {
|
|
302
|
+
expect(this.data?.environment).toBe("test");
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Run Tests
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
bun test
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
To always show the Markdown test report (not just on failure):
|
|
315
|
+
|
|
316
|
+
```sh
|
|
317
|
+
KEST_SHOW_REPORT=1 bun test
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## API Reference
|
|
321
|
+
|
|
322
|
+
### `test(label, callback, options?)`
|
|
323
|
+
|
|
324
|
+
Entry point for defining a test scenario. The callback receives a `Scenario` object.
|
|
325
|
+
|
|
326
|
+
| Option | Type | Default | Description |
|
|
327
|
+
| --------- | -------- | ------- | ------------------------------------ |
|
|
328
|
+
| `timeout` | `string` | `"60s"` | Maximum duration for the entire test |
|
|
329
|
+
|
|
330
|
+
### Scenario
|
|
331
|
+
|
|
332
|
+
The top-level API surface available in every test callback.
|
|
333
|
+
|
|
334
|
+
| Method | Description |
|
|
335
|
+
| ----------------------------------------------------------------------- | ------------------------------------------------ |
|
|
336
|
+
| `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
|
|
337
|
+
| `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
|
|
338
|
+
| `get(resource, options?)` | Fetch a resource by API version, kind, and name |
|
|
339
|
+
| `assert(resource, options?)` | Fetch a resource and run assertions with retries |
|
|
340
|
+
| `assertList(resource, options?)` | Fetch a list of resources and run assertions |
|
|
341
|
+
| `newNamespace(name?, options?)` | Create an ephemeral namespace |
|
|
342
|
+
| `exec(input, options?)` | Execute shell commands with optional revert |
|
|
343
|
+
| `useCluster(ref)` | Create a cluster-bound API surface |
|
|
344
|
+
| `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
|
|
345
|
+
|
|
346
|
+
### Namespace / Cluster
|
|
347
|
+
|
|
348
|
+
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `get`, `assert`, `assertList`, `newNamespace`) scoped to their namespace or cluster context.
|
|
349
|
+
|
|
350
|
+
### Action Options
|
|
351
|
+
|
|
352
|
+
All actions accept an optional options object for retry configuration.
|
|
353
|
+
|
|
354
|
+
| Option | Type | Default | Description |
|
|
355
|
+
| ---------- | -------- | --------- | ---------------------------- |
|
|
356
|
+
| `timeout` | `string` | `"5s"` | Maximum retry duration |
|
|
357
|
+
| `interval` | `string` | `"200ms"` | Delay between retry attempts |
|
|
358
|
+
|
|
359
|
+
Duration strings support units like `"200ms"`, `"5s"`, `"1m"`.
|
|
360
|
+
|
|
361
|
+
## Type Safety
|
|
362
|
+
|
|
363
|
+
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import type { K8sResource } from "@appthrust/kest";
|
|
367
|
+
|
|
368
|
+
interface MyCustomResource extends K8sResource {
|
|
369
|
+
apiVersion: "example.com/v1";
|
|
370
|
+
kind: "MyResource";
|
|
371
|
+
metadata: { name: string };
|
|
372
|
+
spec: {
|
|
373
|
+
replicas: number;
|
|
374
|
+
image: string;
|
|
375
|
+
};
|
|
376
|
+
status?: {
|
|
377
|
+
conditions: Array<{
|
|
378
|
+
type: string;
|
|
379
|
+
status: "True" | "False" | "Unknown";
|
|
380
|
+
}>;
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Full autocompletion and type checking:
|
|
385
|
+
await ns.apply<MyCustomResource>({
|
|
386
|
+
apiVersion: "example.com/v1",
|
|
387
|
+
kind: "MyResource",
|
|
388
|
+
metadata: { name: "my-instance" },
|
|
389
|
+
spec: { replicas: 3, image: "my-app:latest" },
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await ns.assert<MyCustomResource>({
|
|
393
|
+
apiVersion: "example.com/v1",
|
|
394
|
+
kind: "MyResource",
|
|
395
|
+
name: "my-instance",
|
|
396
|
+
test() {
|
|
397
|
+
// `this` is typed as MyCustomResource
|
|
398
|
+
expect(this.spec.replicas).toBe(3);
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Environment Variables
|
|
404
|
+
|
|
405
|
+
| Variable | Description |
|
|
406
|
+
| ------------------ | ----------------------------------------------------------------------- |
|
|
407
|
+
| `KEST_SHOW_REPORT` | Set to `"1"` to show Markdown reports for all tests (not just failures) |
|
|
408
|
+
| `KEST_SHOW_EVENTS` | Set to `"1"` to dump raw recorder events for debugging |
|
|
409
|
+
|
|
410
|
+
## License
|
|
411
|
+
|
|
412
|
+
[MIT](LICENSE)
|