@telorun/assert 0.1.5 → 0.1.7
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 +233 -0
- package/dist/events.js +47 -54
- package/dist/manifest.d.ts +17 -0
- package/dist/manifest.js +111 -0
- package/dist/module-context.js +14 -9
- package/dist/schema.js +8 -8
- package/package.json +24 -4
- package/src/events.ts +140 -0
- package/src/manifest.ts +144 -0
- package/src/module-context.ts +97 -0
- package/src/schema.ts +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# ⚡ Telo
|
|
2
|
+
|
|
3
|
+
Runtime for declarative backends.
|
|
4
|
+
|
|
5
|
+
Telo is an execution engine (Micro-Kernel) that runs logic defined entirely in YAML manifests. Instead of writing imperative backend code, you define your routes, databases, schemas, and AI workflows as atomic, interconnected YAML documents. Telo takes those manifests and runs them.
|
|
6
|
+
|
|
7
|
+
Built to be language-agnostic and infinitely extensible.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Reconcile your manifest into a running backend
|
|
11
|
+
$ telo ./examples/hello-api.yaml
|
|
12
|
+
|
|
13
|
+
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:8844"}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why use Telo?
|
|
17
|
+
|
|
18
|
+
- **Open Standards:** Built on YAML, JSON Schema, and CEL — no proprietary DSL.
|
|
19
|
+
- **Static Analysis:** CEL type checking, reference validation, and IDE diagnostics catch errors before runtime.
|
|
20
|
+
- **Micro-Kernel Architecture:** Telo itself knows nothing about HTTP or SQL. Everything is a module you import, scope, and compose with typed variable and secret contracts.
|
|
21
|
+
- **Language Agnostic:** Available as a Node.js runtime today, with a shared YAML runtime contract that allows for future Rust or Go implementations without changing your manifests.
|
|
22
|
+
|
|
23
|
+
## What It Does
|
|
24
|
+
|
|
25
|
+
- **Loads** YAML resources and compiles CEL expressions (`${{ }}`) into an in-memory registry.
|
|
26
|
+
- **Resolves** resource dependencies via a multi-pass init loop, handling ordering automatically.
|
|
27
|
+
- **Indexes** resources by Kind and Name for constant-time lookup.
|
|
28
|
+
- **Dispatches** execution to the controller that owns each Kind.
|
|
29
|
+
|
|
30
|
+
Manifests also support directives for dynamic generation: `$let`, `$if`, `$for`, `$eval`, and `$include`. See [CEL-YAML Templating](./yaml-cel-templating/README.md) for documentation.
|
|
31
|
+
|
|
32
|
+
## Example manifest
|
|
33
|
+
|
|
34
|
+
Here is an example Telo application that defines a simple HTTP API:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
kind: Kernel.Module
|
|
38
|
+
metadata:
|
|
39
|
+
name: feedback
|
|
40
|
+
version: 1.0.0
|
|
41
|
+
description: |
|
|
42
|
+
A complete feedback collection REST API — no code, pure YAML.
|
|
43
|
+
Persists entries to SQLite and serves them over HTTP.
|
|
44
|
+
targets:
|
|
45
|
+
- Migrations
|
|
46
|
+
- Server
|
|
47
|
+
---
|
|
48
|
+
kind: Kernel.Import
|
|
49
|
+
metadata:
|
|
50
|
+
name: Http
|
|
51
|
+
source: ../modules/http-server
|
|
52
|
+
---
|
|
53
|
+
kind: Kernel.Import
|
|
54
|
+
metadata:
|
|
55
|
+
name: Sql
|
|
56
|
+
source: ../modules/sql
|
|
57
|
+
---
|
|
58
|
+
# SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
|
|
59
|
+
kind: Sql.Connection
|
|
60
|
+
metadata:
|
|
61
|
+
name: Db
|
|
62
|
+
driver: sqlite
|
|
63
|
+
file: ./tmp/feedback.db
|
|
64
|
+
---
|
|
65
|
+
# Migrations: applied automatically before the server starts
|
|
66
|
+
kind: Sql.Migrations
|
|
67
|
+
metadata:
|
|
68
|
+
name: Migrations
|
|
69
|
+
connection:
|
|
70
|
+
kind: Sql.Connection
|
|
71
|
+
name: Db
|
|
72
|
+
---
|
|
73
|
+
kind: Sql.Migration
|
|
74
|
+
metadata:
|
|
75
|
+
name: Migration_20260413_182154_CreateFeedback
|
|
76
|
+
sql: |
|
|
77
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
text TEXT NOT NULL,
|
|
80
|
+
source TEXT,
|
|
81
|
+
score INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
83
|
+
)
|
|
84
|
+
---
|
|
85
|
+
kind: Http.Server
|
|
86
|
+
metadata:
|
|
87
|
+
name: Server
|
|
88
|
+
baseUrl: http://localhost:8844
|
|
89
|
+
port: 8844
|
|
90
|
+
logger: true
|
|
91
|
+
openapi:
|
|
92
|
+
info:
|
|
93
|
+
title: Feedback API
|
|
94
|
+
version: 1.0.0
|
|
95
|
+
mounts:
|
|
96
|
+
- path: /v1
|
|
97
|
+
type: Http.Api.FeedbackRoutes
|
|
98
|
+
---
|
|
99
|
+
kind: Http.Api
|
|
100
|
+
metadata:
|
|
101
|
+
name: FeedbackRoutes
|
|
102
|
+
routes:
|
|
103
|
+
# POST /v1/feedback — insert a new entry, score derived from body length heuristic
|
|
104
|
+
- request:
|
|
105
|
+
path: /feedback
|
|
106
|
+
method: POST
|
|
107
|
+
schema:
|
|
108
|
+
body:
|
|
109
|
+
type: object
|
|
110
|
+
properties:
|
|
111
|
+
text:
|
|
112
|
+
type: string
|
|
113
|
+
minLength: 1
|
|
114
|
+
source:
|
|
115
|
+
type: string
|
|
116
|
+
required: [text]
|
|
117
|
+
handler:
|
|
118
|
+
kind: Sql.Exec
|
|
119
|
+
connection:
|
|
120
|
+
kind: Sql.Connection
|
|
121
|
+
name: Db
|
|
122
|
+
inputs:
|
|
123
|
+
sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
|
|
124
|
+
bindings:
|
|
125
|
+
- "${{ request.body.text }}"
|
|
126
|
+
- "${{ request.body.source }}"
|
|
127
|
+
- "${{ size(request.body.text) }}"
|
|
128
|
+
response:
|
|
129
|
+
- status: 201
|
|
130
|
+
headers:
|
|
131
|
+
Content-Type: application/json
|
|
132
|
+
body:
|
|
133
|
+
ok: true
|
|
134
|
+
message: Feedback received
|
|
135
|
+
|
|
136
|
+
# GET /v1/feedback — list all entries, newest first
|
|
137
|
+
- request:
|
|
138
|
+
path: /feedback
|
|
139
|
+
method: GET
|
|
140
|
+
handler:
|
|
141
|
+
kind: Sql.Select
|
|
142
|
+
connection:
|
|
143
|
+
kind: Sql.Connection
|
|
144
|
+
name: Db
|
|
145
|
+
from: feedback
|
|
146
|
+
columns: [id, text, source, score, created_at]
|
|
147
|
+
orderBy:
|
|
148
|
+
- { column: created_at, direction: desc }
|
|
149
|
+
response:
|
|
150
|
+
- status: 200
|
|
151
|
+
headers:
|
|
152
|
+
Content-Type: application/json
|
|
153
|
+
body: "${{ result.rows }}"
|
|
154
|
+
|
|
155
|
+
# GET /v1/feedback/{id} — fetch a single entry
|
|
156
|
+
- request:
|
|
157
|
+
path: /feedback/{id}
|
|
158
|
+
method: GET
|
|
159
|
+
schema:
|
|
160
|
+
params:
|
|
161
|
+
type: object
|
|
162
|
+
properties:
|
|
163
|
+
id:
|
|
164
|
+
type: integer
|
|
165
|
+
required: [id]
|
|
166
|
+
handler:
|
|
167
|
+
kind: Sql.Select
|
|
168
|
+
connection:
|
|
169
|
+
kind: Sql.Connection
|
|
170
|
+
name: Db
|
|
171
|
+
from: feedback
|
|
172
|
+
columns: [id, text, source, score, created_at]
|
|
173
|
+
where:
|
|
174
|
+
- { column: id, op: "=", value: "${{ request.params.id }}" }
|
|
175
|
+
response:
|
|
176
|
+
- status: 200
|
|
177
|
+
when: "size(result.rows) > 0"
|
|
178
|
+
headers:
|
|
179
|
+
Content-Type: application/json
|
|
180
|
+
body: "${{ result.rows[0] }}"
|
|
181
|
+
- status: 404
|
|
182
|
+
headers:
|
|
183
|
+
Content-Type: application/json
|
|
184
|
+
body:
|
|
185
|
+
ok: false
|
|
186
|
+
message: Not found
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Status
|
|
190
|
+
|
|
191
|
+
Telo is under **active development**. The core runtime, module system, and standard library are functional, but the API surface — including YAML shapes — may change without notice. Not yet recommended for production use.
|
|
192
|
+
|
|
193
|
+
## The Meaning of Telo
|
|
194
|
+
|
|
195
|
+
The name Telo is derived from the Greek root Telos - meaning the "end goal", "purpose", or "final state". That is exactly the philosophy behind this runtime. In standard imperative programming, you have to write thousands of lines of code to tell a server exactly how to start. With Telo, you simply declare your desired final state.
|
|
196
|
+
|
|
197
|
+
You define the end state. Telo makes it real.
|
|
198
|
+
|
|
199
|
+
## Philosophy
|
|
200
|
+
|
|
201
|
+
Modern platforms often spend disproportionate effort on technical mechanics-wiring frameworks, managing infrastructure, and negotiating toolchains-while the original business problem gets delayed or diluted. Telo pushes in the opposite direction: it treats kernel execution as a stable, predictable host so teams can concentrate on the **business logic and outcomes** instead of the plumbing.
|
|
202
|
+
|
|
203
|
+
By separating "what the system should do" from "how it is hosted", the runtime reduces friction for domain‑level changes. Teams can move faster on product requirements, experiment more safely, and keep conversations centered on value delivered rather than implementation trivia.
|
|
204
|
+
|
|
205
|
+
Telo also aims to **join forces across all programming language communities**, so the best ideas, patterns, and implementations can converge into a shared kernel truth without forcing everyone into a single stack.
|
|
206
|
+
|
|
207
|
+
YAML also makes the system more **AI‑friendly** than traditional programming languages: it is explicit, structured, and easier for tools to generate, review, and transform without losing intent.
|
|
208
|
+
|
|
209
|
+
## Modularity
|
|
210
|
+
|
|
211
|
+
Telo is built around **modules** that own specific resource kinds. A module is loaded from a manifest, declares which kinds it implements, and then receives only the resources of those kinds. This keeps concerns isolated and lets teams compose systems from focused building blocks rather than monolithic services.
|
|
212
|
+
|
|
213
|
+
At kernel execution time, execution is always routed by **Kind.Name**. The kernel resolves the Kind to its owning module and hands off execution. Modules can call back into the kernel to execute other resources, enabling composition without tight coupling.
|
|
214
|
+
|
|
215
|
+
## Architecture
|
|
216
|
+
|
|
217
|
+
The architecture is inspired by Kubernetes-style manifests: declarative resources, explicit kinds, and a control plane that routes work based on those definitions.
|
|
218
|
+
Those manifests were taken to the next level by allowing them to run inside a standalone runtime host.
|
|
219
|
+
|
|
220
|
+
## See more at
|
|
221
|
+
|
|
222
|
+
- [Telo Kernel](./kernel/README.md)
|
|
223
|
+
- [CEL-YAML Templating](./yaml-cel-templating/README.md)
|
|
224
|
+
- [Telo SDK for module authors](sdk/README.md)
|
|
225
|
+
- [Modules](modules/README.md)
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
See [LICENSE](https://github.com/telorun/telo/blob/main/LICENSE).
|
|
230
|
+
|
|
231
|
+
## Contribution Note
|
|
232
|
+
|
|
233
|
+
By contributing, you agree that code and examples in this repository may be translated or re‑implemented in other programming languages (including by AI systems) to support the project’s polyglot goals.
|
package/dist/events.js
CHANGED
|
@@ -45,67 +45,60 @@ function matchesPayload(actual, expected) {
|
|
|
45
45
|
}
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
48
|
+
export async function create(manifest, ctx) {
|
|
49
|
+
const useColor = ctx.stderr.isTTY ?? false;
|
|
50
|
+
const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
51
|
+
const bold = (t) => c("1", t);
|
|
52
|
+
const red = (t) => c("31", t);
|
|
53
|
+
const green = (t) => c("32", t);
|
|
54
|
+
const yellow = (t) => c("33", t);
|
|
55
|
+
const dim = (t) => c("2", t);
|
|
56
|
+
function buildReport(name, captured, expect) {
|
|
57
|
+
const results = [];
|
|
58
|
+
let pos = 0;
|
|
59
|
+
for (const entry of expect) {
|
|
60
|
+
let found = false;
|
|
61
|
+
while (pos < captured.length) {
|
|
62
|
+
const ev = captured[pos++];
|
|
63
|
+
if (matchesPattern(entry.event, ev.name)) {
|
|
64
|
+
if (!entry.payload || matchesPayload(ev.payload, entry.payload)) {
|
|
65
|
+
results.push({ status: "matched", entry, actual: ev });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
results.push({ status: "payload-mismatch", entry, actual: ev });
|
|
69
|
+
}
|
|
70
|
+
found = true;
|
|
71
|
+
break;
|
|
68
72
|
}
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
}
|
|
74
|
+
if (!found) {
|
|
75
|
+
results.push({ status: "not-found", entry });
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
report += ` ${red("✗")} ${result.entry.event} ${dim("← not found in stream")}\n`;
|
|
90
|
-
if (result.entry.payload) {
|
|
78
|
+
const failures = results.filter((r) => r.status !== "matched");
|
|
79
|
+
let report = bold(failures.length > 0
|
|
80
|
+
? red(`Assert.Events.${name}: assertion failed`)
|
|
81
|
+
: green(`Assert.Events.${name}: assertion passed`)) + "\n";
|
|
82
|
+
for (const result of results) {
|
|
83
|
+
if (result.status === "matched") {
|
|
84
|
+
report += ` ${green("✓")} ${dim(result.actual.name)}\n`;
|
|
85
|
+
}
|
|
86
|
+
else if (result.status === "not-found") {
|
|
87
|
+
report += ` ${red("✗")} ${result.entry.event} ${dim("← not found in stream")}\n`;
|
|
88
|
+
if (result.entry.payload) {
|
|
89
|
+
report += ` ${dim("expected payload:")} ${yellow(JSON.stringify(result.entry.payload))}\n`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (result.status === "payload-mismatch") {
|
|
93
|
+
report += ` ${red("✗")} ${result.actual.name}\n`;
|
|
91
94
|
report += ` ${dim("expected payload:")} ${yellow(JSON.stringify(result.entry.payload))}\n`;
|
|
95
|
+
report += ` ${dim("actual payload: ")} ${red(JSON.stringify(result.actual.payload))}\n`;
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
report += ` ${red("✗")} ${result.actual.name}\n`;
|
|
96
|
-
report += ` ${dim("expected payload:")} ${yellow(JSON.stringify(result.entry.payload))}\n`;
|
|
97
|
-
report += ` ${dim("actual payload: ")} ${red(JSON.stringify(result.actual.payload))}\n`;
|
|
98
|
-
}
|
|
98
|
+
return { report, passed: failures.length === 0 };
|
|
99
99
|
}
|
|
100
|
-
return { report, passed: failures.length === 0 };
|
|
101
|
-
}
|
|
102
|
-
export async function create(manifest, ctx) {
|
|
103
100
|
const captured = [];
|
|
104
101
|
const filters = manifest.filter ?? [{ type: "*" }];
|
|
105
|
-
let appStarted = false;
|
|
106
|
-
ctx.on("Kernel.Started", () => {
|
|
107
|
-
appStarted = true;
|
|
108
|
-
});
|
|
109
102
|
ctx.on("*", (event) => {
|
|
110
103
|
if (filters.some((f) => matchesPattern(f.type, event.name))) {
|
|
111
104
|
captured.push({ name: event.name, payload: event.payload });
|
|
@@ -116,10 +109,10 @@ export async function create(manifest, ctx) {
|
|
|
116
109
|
const report = buildReport(manifest.metadata.name, captured, manifest.expect);
|
|
117
110
|
if (report) {
|
|
118
111
|
if (report.passed) {
|
|
119
|
-
|
|
112
|
+
ctx.stdout.write(report.report);
|
|
120
113
|
}
|
|
121
114
|
else {
|
|
122
|
-
|
|
115
|
+
ctx.stderr.write(report.report);
|
|
123
116
|
ctx.requestExit(1);
|
|
124
117
|
}
|
|
125
118
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResourceContext, Runnable } from "@telorun/sdk";
|
|
2
|
+
interface ExpectError {
|
|
3
|
+
code?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
}
|
|
6
|
+
interface ManifestAssertManifest {
|
|
7
|
+
metadata: {
|
|
8
|
+
name: string;
|
|
9
|
+
module?: string;
|
|
10
|
+
};
|
|
11
|
+
source: string;
|
|
12
|
+
expect: {
|
|
13
|
+
errors?: ExpectError[];
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function create(manifest: ManifestAssertManifest, ctx: ResourceContext): Promise<Runnable>;
|
|
17
|
+
export {};
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, Loader, StaticAnalyzer } from "@telorun/analyzer";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
class LocalFileAdapter {
|
|
5
|
+
supports(p) {
|
|
6
|
+
return (p.startsWith("file://") ||
|
|
7
|
+
p.startsWith("/") ||
|
|
8
|
+
p.startsWith("./") ||
|
|
9
|
+
p.startsWith("../") ||
|
|
10
|
+
(!p.includes("://") && !p.includes("@")));
|
|
11
|
+
}
|
|
12
|
+
async read(p) {
|
|
13
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
14
|
+
const resolved = path.resolve(norm);
|
|
15
|
+
const stat = await fs.stat(resolved);
|
|
16
|
+
const filePath = stat.isDirectory() ? path.join(resolved, DEFAULT_MANIFEST_FILENAME) : resolved;
|
|
17
|
+
const text = await fs.readFile(filePath, "utf-8");
|
|
18
|
+
return { text, source: `file://${filePath}` };
|
|
19
|
+
}
|
|
20
|
+
async readAll(p) {
|
|
21
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
22
|
+
const resolved = path.resolve(norm);
|
|
23
|
+
const stat = await fs.stat(resolved);
|
|
24
|
+
if (stat.isDirectory()) {
|
|
25
|
+
const entries = await fs.readdir(resolved);
|
|
26
|
+
return entries
|
|
27
|
+
.filter((e) => e.endsWith(".yaml") || e.endsWith(".yml"))
|
|
28
|
+
.map((e) => `file://${path.join(resolved, e)}`);
|
|
29
|
+
}
|
|
30
|
+
return [`file://${resolved}`];
|
|
31
|
+
}
|
|
32
|
+
resolveRelative(base, relative) {
|
|
33
|
+
const basePath = base.startsWith("file://") ? new URL(base).pathname : base;
|
|
34
|
+
const baseDir = basePath.endsWith("/") ? basePath : path.dirname(basePath);
|
|
35
|
+
return `file://${path.resolve(baseDir, relative)}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function matchesDiagnostic(diag, expected) {
|
|
39
|
+
if (expected.code && diag.code !== expected.code)
|
|
40
|
+
return false;
|
|
41
|
+
if (expected.message && !diag.message.includes(expected.message))
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
export async function create(manifest, ctx) {
|
|
46
|
+
return {
|
|
47
|
+
run: async () => {
|
|
48
|
+
const useColor = ctx.stderr.isTTY ?? false;
|
|
49
|
+
const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
50
|
+
const bold = (t) => c("1", t);
|
|
51
|
+
const red = (t) => c("31", t);
|
|
52
|
+
const green = (t) => c("32", t);
|
|
53
|
+
const dim = (t) => c("2", t);
|
|
54
|
+
const name = manifest.metadata.name;
|
|
55
|
+
const loader = new Loader([new LocalFileAdapter()]);
|
|
56
|
+
const analyzer = new StaticAnalyzer();
|
|
57
|
+
const resolvedUrl = new URL(manifest.source, ctx.moduleContext.source).toString();
|
|
58
|
+
let manifests;
|
|
59
|
+
try {
|
|
60
|
+
manifests = await loader.loadManifests(resolvedUrl);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
ctx.stderr.write(bold(red(`Assert.Manifest.${name}: failed to load "${manifest.source}"`)) +
|
|
64
|
+
"\n " + (err instanceof Error ? err.message : String(err)) + "\n");
|
|
65
|
+
ctx.requestExit(1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const diagnostics = analyzer.analyze(manifests);
|
|
69
|
+
const errors = diagnostics.filter((d) => d.severity === 1); // DiagnosticSeverity.Error = 1
|
|
70
|
+
const expectedErrors = manifest.expect.errors ?? [];
|
|
71
|
+
const failures = [];
|
|
72
|
+
const matched = [];
|
|
73
|
+
if (expectedErrors.length === 0) {
|
|
74
|
+
// Expect zero errors — any error is a failure
|
|
75
|
+
if (errors.length > 0) {
|
|
76
|
+
for (const d of errors) {
|
|
77
|
+
failures.push(`unexpected error: [${d.code}] ${d.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
matched.push("no errors");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
for (const expected of expectedErrors) {
|
|
86
|
+
const match = errors.find((d) => matchesDiagnostic(d, expected));
|
|
87
|
+
if (match) {
|
|
88
|
+
matched.push(`${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
failures.push(`expected ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const passedLines = matched.map((m) => ` ${green("✓")} ${dim(m)}\n`).join("");
|
|
96
|
+
if (failures.length > 0) {
|
|
97
|
+
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
98
|
+
const actualLines = errors.length > 0
|
|
99
|
+
? ` ${dim("actual errors:")}\n` +
|
|
100
|
+
errors.map((d) => ` ${dim(`[${d.code}] ${d.message}`)}\n`).join("")
|
|
101
|
+
: ` ${dim("no errors produced")}\n`;
|
|
102
|
+
ctx.stderr.write(bold(red(`Assert.Manifest.${name}: assertion failed`)) + "\n" +
|
|
103
|
+
passedLines + failedLines + actualLines);
|
|
104
|
+
ctx.requestExit(1);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
ctx.stdout.write(bold(green(`Assert.Manifest.${name}: assertion passed`)) + "\n" + passedLines);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/module-context.js
CHANGED
|
@@ -12,13 +12,17 @@ export const schema = Type.Object({
|
|
|
12
12
|
variables: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
13
13
|
secrets: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
14
14
|
});
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
function createColors(stream) {
|
|
16
|
+
const useColor = stream.isTTY ?? false;
|
|
17
|
+
const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
18
|
+
return {
|
|
19
|
+
bold: (t) => c("1", t),
|
|
20
|
+
red: (t) => c("31", t),
|
|
21
|
+
green: (t) => c("32", t),
|
|
22
|
+
yellow: (t) => c("33", t),
|
|
23
|
+
dim: (t) => c("2", t),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
22
26
|
function deepEqual(a, b) {
|
|
23
27
|
if (a === b)
|
|
24
28
|
return true;
|
|
@@ -39,6 +43,7 @@ function deepEqual(a, b) {
|
|
|
39
43
|
export async function create(manifest, ctx) {
|
|
40
44
|
return {
|
|
41
45
|
run: async () => {
|
|
46
|
+
const { bold, red, green, yellow, dim } = createColors(ctx.stderr);
|
|
42
47
|
const declaringModule = manifest.metadata.module ?? "default";
|
|
43
48
|
const resourcesToCheck = manifest.resources ?? {};
|
|
44
49
|
const failures = [];
|
|
@@ -74,11 +79,11 @@ export async function create(manifest, ctx) {
|
|
|
74
79
|
const passedLines = passed.map((p) => ` ${green("✓")} ${dim(p)}\n`).join("");
|
|
75
80
|
if (failures.length > 0) {
|
|
76
81
|
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
77
|
-
|
|
82
|
+
ctx.stderr.write(bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n" + passedLines + failedLines);
|
|
78
83
|
ctx.requestExit(1);
|
|
79
84
|
}
|
|
80
85
|
else {
|
|
81
|
-
|
|
86
|
+
ctx.stdout.write(bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n" + passedLines);
|
|
82
87
|
}
|
|
83
88
|
},
|
|
84
89
|
};
|
package/dist/schema.js
CHANGED
|
@@ -7,24 +7,24 @@ export const schema = Type.Object({
|
|
|
7
7
|
type: Type.String(),
|
|
8
8
|
}),
|
|
9
9
|
});
|
|
10
|
-
const useColor = process.stderr.isTTY;
|
|
11
|
-
const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
12
|
-
const bold = (t) => c("1", t);
|
|
13
|
-
const red = (t) => c("31", t);
|
|
14
|
-
const green = (t) => c("32", t);
|
|
15
|
-
const dim = (t) => c("2", t);
|
|
16
10
|
export async function create(manifest, ctx) {
|
|
11
|
+
const useColor = ctx.stderr.isTTY ?? false;
|
|
12
|
+
const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
13
|
+
const bold = (t) => c("1", t);
|
|
14
|
+
const red = (t) => c("31", t);
|
|
15
|
+
const green = (t) => c("32", t);
|
|
16
|
+
const dim = (t) => c("2", t);
|
|
17
17
|
const validator = ctx.createSchemaValidator(manifest.schema);
|
|
18
18
|
const name = manifest.metadata.name;
|
|
19
19
|
return {
|
|
20
20
|
invoke: (data) => {
|
|
21
21
|
try {
|
|
22
22
|
validator.validate(data);
|
|
23
|
-
|
|
23
|
+
ctx.stdout.write(bold(green(`Assert.Schema.${name}: assertion passed`)) + "\n" + ` ${green("✓")} ${dim(JSON.stringify(data))}\n`);
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
26
|
catch (err) {
|
|
27
|
-
|
|
27
|
+
ctx.stderr.write(bold(red(`Assert.Schema.${name}: assertion failed`)) + "\n" + ` ${red("✗")} ${err?.message ?? String(err)}\n`);
|
|
28
28
|
throw err;
|
|
29
29
|
}
|
|
30
30
|
},
|
package/package.json
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/assert",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Telo Assert module - Assertion resource kinds for Telo manifests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"assert",
|
|
8
|
+
"testing"
|
|
9
|
+
],
|
|
10
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
11
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
15
|
+
"directory": "modules/assert/nodejs"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
20
|
+
},
|
|
4
21
|
"type": "module",
|
|
5
22
|
"exports": {
|
|
6
23
|
"./schema": "./src/schema.ts",
|
|
7
24
|
"./events": "./src/events.ts",
|
|
8
|
-
"./module-context": "./src/module-context.ts"
|
|
25
|
+
"./module-context": "./src/module-context.ts",
|
|
26
|
+
"./manifest": "./src/manifest.ts"
|
|
9
27
|
},
|
|
10
28
|
"files": [
|
|
11
|
-
"dist
|
|
29
|
+
"dist",
|
|
30
|
+
"src/**"
|
|
12
31
|
],
|
|
13
32
|
"dependencies": {
|
|
14
33
|
"@sinclair/typebox": "^0.34.48",
|
|
15
|
-
"@telorun/
|
|
34
|
+
"@telorun/analyzer": "0.1.3",
|
|
35
|
+
"@telorun/sdk": "0.2.8"
|
|
16
36
|
},
|
|
17
37
|
"devDependencies": {
|
|
18
38
|
"@types/node": "^20.0.0",
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { ResourceContext } from "@telorun/sdk";
|
|
3
|
+
|
|
4
|
+
const FilterEntry = Type.Object({
|
|
5
|
+
type: Type.String(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const ExpectEntry = Type.Object({
|
|
9
|
+
event: Type.String(),
|
|
10
|
+
payload: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const schema = Type.Object({
|
|
14
|
+
metadata: Type.Object({
|
|
15
|
+
name: Type.String(),
|
|
16
|
+
}),
|
|
17
|
+
filter: Type.Optional(Type.Array(FilterEntry)),
|
|
18
|
+
expect: Type.Array(ExpectEntry),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type AssertManifest = Static<typeof schema>;
|
|
22
|
+
|
|
23
|
+
type CapturedEvent = {
|
|
24
|
+
name: string;
|
|
25
|
+
payload?: any;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ExpectEntry = Static<typeof ExpectEntry>;
|
|
29
|
+
|
|
30
|
+
type MatchResult =
|
|
31
|
+
| { status: "matched"; entry: ExpectEntry; actual: CapturedEvent }
|
|
32
|
+
| { status: "payload-mismatch"; entry: ExpectEntry; actual: CapturedEvent }
|
|
33
|
+
| { status: "not-found"; entry: ExpectEntry };
|
|
34
|
+
|
|
35
|
+
function matchesPattern(pattern: string, eventName: string): boolean {
|
|
36
|
+
if (pattern === "*") return true;
|
|
37
|
+
if (pattern === eventName) return true;
|
|
38
|
+
if (!pattern.includes("*")) return false;
|
|
39
|
+
const patternParts = pattern.split(".");
|
|
40
|
+
const eventParts = eventName.split(".");
|
|
41
|
+
if (patternParts.length !== eventParts.length) return false;
|
|
42
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
43
|
+
if (patternParts[i] !== "*" && patternParts[i] !== eventParts[i]) return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchesPayload(actual: any, expected: Record<string, any>): boolean {
|
|
49
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
50
|
+
if (actual == null) return false;
|
|
51
|
+
if (typeof value === "object" && value !== null) {
|
|
52
|
+
if (!matchesPayload(actual[key], value)) return false;
|
|
53
|
+
} else {
|
|
54
|
+
if (actual[key] !== value) return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function create(manifest: AssertManifest, ctx: ResourceContext) {
|
|
61
|
+
const useColor = (ctx.stderr as any).isTTY ?? false;
|
|
62
|
+
const c = (code: string, text: string) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
63
|
+
const bold = (t: string) => c("1", t);
|
|
64
|
+
const red = (t: string) => c("31", t);
|
|
65
|
+
const green = (t: string) => c("32", t);
|
|
66
|
+
const yellow = (t: string) => c("33", t);
|
|
67
|
+
const dim = (t: string) => c("2", t);
|
|
68
|
+
|
|
69
|
+
function buildReport(name: string, captured: CapturedEvent[], expect: ExpectEntry[]) {
|
|
70
|
+
const results: MatchResult[] = [];
|
|
71
|
+
let pos = 0;
|
|
72
|
+
|
|
73
|
+
for (const entry of expect) {
|
|
74
|
+
let found = false;
|
|
75
|
+
while (pos < captured.length) {
|
|
76
|
+
const ev = captured[pos++];
|
|
77
|
+
if (matchesPattern(entry.event, ev.name)) {
|
|
78
|
+
if (!entry.payload || matchesPayload(ev.payload, entry.payload)) {
|
|
79
|
+
results.push({ status: "matched", entry, actual: ev });
|
|
80
|
+
} else {
|
|
81
|
+
results.push({ status: "payload-mismatch", entry, actual: ev });
|
|
82
|
+
}
|
|
83
|
+
found = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!found) {
|
|
88
|
+
results.push({ status: "not-found", entry });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const failures = results.filter((r) => r.status !== "matched");
|
|
93
|
+
|
|
94
|
+
let report =
|
|
95
|
+
bold(
|
|
96
|
+
failures.length > 0
|
|
97
|
+
? red(`Assert.Events.${name}: assertion failed`)
|
|
98
|
+
: green(`Assert.Events.${name}: assertion passed`),
|
|
99
|
+
) + "\n";
|
|
100
|
+
for (const result of results) {
|
|
101
|
+
if (result.status === "matched") {
|
|
102
|
+
report += ` ${green("✓")} ${dim(result.actual.name)}\n`;
|
|
103
|
+
} else if (result.status === "not-found") {
|
|
104
|
+
report += ` ${red("✗")} ${result.entry.event} ${dim("← not found in stream")}\n`;
|
|
105
|
+
if (result.entry.payload) {
|
|
106
|
+
report += ` ${dim("expected payload:")} ${yellow(JSON.stringify(result.entry.payload))}\n`;
|
|
107
|
+
}
|
|
108
|
+
} else if (result.status === "payload-mismatch") {
|
|
109
|
+
report += ` ${red("✗")} ${result.actual.name}\n`;
|
|
110
|
+
report += ` ${dim("expected payload:")} ${yellow(JSON.stringify(result.entry.payload))}\n`;
|
|
111
|
+
report += ` ${dim("actual payload: ")} ${red(JSON.stringify(result.actual.payload))}\n`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { report, passed: failures.length === 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const captured: CapturedEvent[] = [];
|
|
119
|
+
const filters = manifest.filter ?? [{ type: "*" }];
|
|
120
|
+
|
|
121
|
+
ctx.on("*", (event) => {
|
|
122
|
+
if (filters.some((f) => matchesPattern(f.type, event.name))) {
|
|
123
|
+
captured.push({ name: event.name, payload: event.payload });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
run: async () => {
|
|
129
|
+
const report = buildReport(manifest.metadata.name, captured, manifest.expect);
|
|
130
|
+
if (report) {
|
|
131
|
+
if (report.passed) {
|
|
132
|
+
ctx.stdout.write(report.report);
|
|
133
|
+
} else {
|
|
134
|
+
ctx.stderr.write(report.report);
|
|
135
|
+
ctx.requestExit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, Loader, StaticAnalyzer, type AnalysisDiagnostic, type ManifestAdapter } from "@telorun/analyzer";
|
|
2
|
+
import type { ResourceContext, Runnable } from "@telorun/sdk";
|
|
3
|
+
import * as fs from "fs/promises";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
interface ExpectError {
|
|
7
|
+
code?: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ManifestAssertManifest {
|
|
12
|
+
metadata: { name: string; module?: string };
|
|
13
|
+
source: string;
|
|
14
|
+
expect: {
|
|
15
|
+
errors?: ExpectError[];
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class LocalFileAdapter implements ManifestAdapter {
|
|
20
|
+
supports(p: string): boolean {
|
|
21
|
+
return (
|
|
22
|
+
p.startsWith("file://") ||
|
|
23
|
+
p.startsWith("/") ||
|
|
24
|
+
p.startsWith("./") ||
|
|
25
|
+
p.startsWith("../") ||
|
|
26
|
+
(!p.includes("://") && !p.includes("@"))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async read(p: string): Promise<{ text: string; source: string }> {
|
|
31
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
32
|
+
const resolved = path.resolve(norm);
|
|
33
|
+
const stat = await fs.stat(resolved);
|
|
34
|
+
const filePath = stat.isDirectory() ? path.join(resolved, DEFAULT_MANIFEST_FILENAME) : resolved;
|
|
35
|
+
const text = await fs.readFile(filePath, "utf-8");
|
|
36
|
+
return { text, source: `file://${filePath}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async readAll(p: string): Promise<string[]> {
|
|
40
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
41
|
+
const resolved = path.resolve(norm);
|
|
42
|
+
const stat = await fs.stat(resolved);
|
|
43
|
+
if (stat.isDirectory()) {
|
|
44
|
+
const entries = await fs.readdir(resolved);
|
|
45
|
+
return entries
|
|
46
|
+
.filter((e) => e.endsWith(".yaml") || e.endsWith(".yml"))
|
|
47
|
+
.map((e) => `file://${path.join(resolved, e)}`);
|
|
48
|
+
}
|
|
49
|
+
return [`file://${resolved}`];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resolveRelative(base: string, relative: string): string {
|
|
53
|
+
const basePath = base.startsWith("file://") ? new URL(base).pathname : base;
|
|
54
|
+
const baseDir = basePath.endsWith("/") ? basePath : path.dirname(basePath);
|
|
55
|
+
return `file://${path.resolve(baseDir, relative)}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matchesDiagnostic(diag: AnalysisDiagnostic, expected: ExpectError): boolean {
|
|
60
|
+
if (expected.code && diag.code !== expected.code) return false;
|
|
61
|
+
if (expected.message && !diag.message.includes(expected.message)) return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function create(
|
|
66
|
+
manifest: ManifestAssertManifest,
|
|
67
|
+
ctx: ResourceContext,
|
|
68
|
+
): Promise<Runnable> {
|
|
69
|
+
return {
|
|
70
|
+
run: async () => {
|
|
71
|
+
const useColor = (ctx.stderr as any).isTTY ?? false;
|
|
72
|
+
const c = (code: string, text: string) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
73
|
+
const bold = (t: string) => c("1", t);
|
|
74
|
+
const red = (t: string) => c("31", t);
|
|
75
|
+
const green = (t: string) => c("32", t);
|
|
76
|
+
const dim = (t: string) => c("2", t);
|
|
77
|
+
|
|
78
|
+
const name = manifest.metadata.name;
|
|
79
|
+
const loader = new Loader([new LocalFileAdapter()]);
|
|
80
|
+
const analyzer = new StaticAnalyzer();
|
|
81
|
+
|
|
82
|
+
const resolvedUrl = new URL(manifest.source, ctx.moduleContext.source).toString();
|
|
83
|
+
let manifests;
|
|
84
|
+
try {
|
|
85
|
+
manifests = await loader.loadManifests(resolvedUrl);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
ctx.stderr.write(
|
|
88
|
+
bold(red(`Assert.Manifest.${name}: failed to load "${manifest.source}"`)) +
|
|
89
|
+
"\n " + (err instanceof Error ? err.message : String(err)) + "\n",
|
|
90
|
+
);
|
|
91
|
+
ctx.requestExit(1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const diagnostics = analyzer.analyze(manifests);
|
|
96
|
+
const errors = diagnostics.filter((d) => d.severity === 1); // DiagnosticSeverity.Error = 1
|
|
97
|
+
const expectedErrors = manifest.expect.errors ?? [];
|
|
98
|
+
const failures: string[] = [];
|
|
99
|
+
const matched: string[] = [];
|
|
100
|
+
|
|
101
|
+
if (expectedErrors.length === 0) {
|
|
102
|
+
// Expect zero errors — any error is a failure
|
|
103
|
+
if (errors.length > 0) {
|
|
104
|
+
for (const d of errors) {
|
|
105
|
+
failures.push(`unexpected error: [${d.code}] ${d.message}`);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
matched.push("no errors");
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
for (const expected of expectedErrors) {
|
|
112
|
+
const match = errors.find((d) => matchesDiagnostic(d, expected));
|
|
113
|
+
if (match) {
|
|
114
|
+
matched.push(
|
|
115
|
+
`${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`,
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
failures.push(
|
|
119
|
+
`expected ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const passedLines = matched.map((m) => ` ${green("✓")} ${dim(m)}\n`).join("");
|
|
126
|
+
if (failures.length > 0) {
|
|
127
|
+
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
128
|
+
const actualLines = errors.length > 0
|
|
129
|
+
? ` ${dim("actual errors:")}\n` +
|
|
130
|
+
errors.map((d) => ` ${dim(`[${d.code}] ${d.message}`)}\n`).join("")
|
|
131
|
+
: ` ${dim("no errors produced")}\n`;
|
|
132
|
+
ctx.stderr.write(
|
|
133
|
+
bold(red(`Assert.Manifest.${name}: assertion failed`)) + "\n" +
|
|
134
|
+
passedLines + failedLines + actualLines,
|
|
135
|
+
);
|
|
136
|
+
ctx.requestExit(1);
|
|
137
|
+
} else {
|
|
138
|
+
ctx.stdout.write(
|
|
139
|
+
bold(green(`Assert.Manifest.${name}: assertion passed`)) + "\n" + passedLines,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Static, Type } from "@sinclair/typebox";
|
|
2
|
+
import { ResourceContext, Runnable } from "@telorun/sdk";
|
|
3
|
+
|
|
4
|
+
const ImportEntry = Type.Object({
|
|
5
|
+
variables: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
6
|
+
secrets: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const schema = Type.Object({
|
|
10
|
+
metadata: Type.Object({
|
|
11
|
+
name: Type.String(),
|
|
12
|
+
module: Type.Optional(Type.String()),
|
|
13
|
+
}),
|
|
14
|
+
resources: Type.Optional(Type.Record(Type.String(), ImportEntry)),
|
|
15
|
+
variables: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
16
|
+
secrets: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type ModuleContextManifest = Static<typeof schema>;
|
|
20
|
+
|
|
21
|
+
function createColors(stream: NodeJS.WritableStream) {
|
|
22
|
+
const useColor = (stream as any).isTTY ?? false;
|
|
23
|
+
const c = (code: string, text: string) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
24
|
+
return {
|
|
25
|
+
bold: (t: string) => c("1", t),
|
|
26
|
+
red: (t: string) => c("31", t),
|
|
27
|
+
green: (t: string) => c("32", t),
|
|
28
|
+
yellow: (t: string) => c("33", t),
|
|
29
|
+
dim: (t: string) => c("2", t),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
34
|
+
if (a === b) return true;
|
|
35
|
+
if (a === null || b === null) return false;
|
|
36
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
37
|
+
const aKeys = Object.keys(a as object);
|
|
38
|
+
const bKeys = Object.keys(b as object);
|
|
39
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
40
|
+
for (const key of aKeys) {
|
|
41
|
+
if (!deepEqual((a as any)[key], (b as any)[key])) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function create(
|
|
47
|
+
manifest: ModuleContextManifest,
|
|
48
|
+
ctx: ResourceContext,
|
|
49
|
+
): Promise<Runnable> {
|
|
50
|
+
return {
|
|
51
|
+
run: async () => {
|
|
52
|
+
const { bold, red, green, yellow, dim } = createColors(ctx.stderr);
|
|
53
|
+
const declaringModule = manifest.metadata.module ?? "default";
|
|
54
|
+
const resourcesToCheck = manifest.resources ?? {};
|
|
55
|
+
const failures: string[] = [];
|
|
56
|
+
const passed: string[] = [];
|
|
57
|
+
const { resources } = ctx.moduleContext;
|
|
58
|
+
for (const [alias, expected] of Object.entries(resourcesToCheck)) {
|
|
59
|
+
if (!(ctx as any).resolveModuleAlias(declaringModule, alias)) {
|
|
60
|
+
failures.push(`Import alias '${alias}' not found in module '${declaringModule}'`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const snap = (resources[alias] as any) ?? {};
|
|
65
|
+
const path = `resources.${alias}`;
|
|
66
|
+
|
|
67
|
+
for (const [key, expectedValue] of Object.entries(expected.variables ?? {})) {
|
|
68
|
+
const actual = snap?.variables?.[key];
|
|
69
|
+
if (deepEqual(actual, expectedValue)) {
|
|
70
|
+
passed.push(`${path}.variables.${key}`);
|
|
71
|
+
} else {
|
|
72
|
+
failures.push(`${path}.variables.${key}: expected ${yellow(JSON.stringify(expectedValue))}, got ${red(JSON.stringify(actual))}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [key, expectedValue] of Object.entries(expected.secrets ?? {})) {
|
|
77
|
+
const actual = snap?.secrets?.[key];
|
|
78
|
+
if (deepEqual(actual, expectedValue)) {
|
|
79
|
+
passed.push(`${path}.secrets.${key}`);
|
|
80
|
+
} else {
|
|
81
|
+
failures.push(`${path}.secrets.${key}: ${dim("value mismatch")}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const name = manifest.metadata.name;
|
|
87
|
+
const passedLines = passed.map((p) => ` ${green("✓")} ${dim(p)}\n`).join("");
|
|
88
|
+
if (failures.length > 0) {
|
|
89
|
+
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
90
|
+
ctx.stderr.write(bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n" + passedLines + failedLines);
|
|
91
|
+
ctx.requestExit(1);
|
|
92
|
+
} else {
|
|
93
|
+
ctx.stdout.write(bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n" + passedLines);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { Static, Type } from "@sinclair/typebox";
|
|
3
|
+
|
|
4
|
+
export const schema = Type.Object({
|
|
5
|
+
metadata: Type.Object({
|
|
6
|
+
name: Type.String(),
|
|
7
|
+
}),
|
|
8
|
+
schema: Type.Object({
|
|
9
|
+
type: Type.String(),
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
type AssertManifest = Static<typeof schema>;
|
|
14
|
+
|
|
15
|
+
export async function create(manifest: AssertManifest, ctx: ResourceContext) {
|
|
16
|
+
const useColor = (ctx.stderr as any).isTTY ?? false;
|
|
17
|
+
const c = (code: string, text: string) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
18
|
+
const bold = (t: string) => c("1", t);
|
|
19
|
+
const red = (t: string) => c("31", t);
|
|
20
|
+
const green = (t: string) => c("32", t);
|
|
21
|
+
const dim = (t: string) => c("2", t);
|
|
22
|
+
|
|
23
|
+
const validator = ctx.createSchemaValidator(manifest.schema);
|
|
24
|
+
const name = manifest.metadata.name;
|
|
25
|
+
return {
|
|
26
|
+
invoke: (data: any) => {
|
|
27
|
+
try {
|
|
28
|
+
validator.validate(data);
|
|
29
|
+
ctx.stdout.write(bold(green(`Assert.Schema.${name}: assertion passed`)) + "\n" + ` ${green("✓")} ${dim(JSON.stringify(data))}\n`);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
ctx.stderr.write(bold(red(`Assert.Schema.${name}: assertion failed`)) + "\n" + ` ${red("✗")} ${err?.message ?? String(err)}\n`);
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|