@telorun/assert 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 +17 -0
- package/README.md +246 -0
- package/dist/colors.d.ts +12 -0
- package/dist/colors.js +15 -0
- package/dist/contains.d.ts +16 -0
- package/dist/contains.js +53 -0
- package/dist/deep-equals.d.ts +13 -0
- package/dist/deep-equals.js +48 -0
- package/dist/equals.d.ts +16 -0
- package/dist/equals.js +29 -0
- package/dist/events.d.ts +19 -0
- package/dist/events.js +121 -0
- package/dist/manifest.d.ts +19 -0
- package/dist/manifest.js +153 -0
- package/dist/matches.d.ts +17 -0
- package/dist/matches.js +45 -0
- package/dist/module-context.d.ts +17 -0
- package/dist/module-context.js +89 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +32 -0
- package/package.json +77 -0
- package/src/colors.ts +18 -0
- package/src/contains.ts +74 -0
- package/src/deep-equals.ts +45 -0
- package/src/equals.ts +43 -0
- package/src/events.ts +140 -0
- package/src/manifest.ts +198 -0
- package/src/matches.ts +69 -0
- package/src/module-context.ts +96 -0
- package/src/schema.ts +37 -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,198 @@
|
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, Loader, StaticAnalyzer, flattenForAnalyzer, type AnalysisDiagnostic, type ManifestSource } 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
|
+
warnings?: ExpectError[];
|
|
17
|
+
loadError?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class LocalFileSource implements ManifestSource {
|
|
22
|
+
supports(p: string): boolean {
|
|
23
|
+
return (
|
|
24
|
+
p.startsWith("file://") ||
|
|
25
|
+
p.startsWith("/") ||
|
|
26
|
+
p.startsWith("./") ||
|
|
27
|
+
p.startsWith("../") ||
|
|
28
|
+
(!p.includes("://") && !p.includes("@"))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async read(p: string): Promise<{ text: string; source: string }> {
|
|
33
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
34
|
+
const resolved = path.resolve(norm);
|
|
35
|
+
const stat = await fs.stat(resolved);
|
|
36
|
+
const filePath = stat.isDirectory() ? path.join(resolved, DEFAULT_MANIFEST_FILENAME) : resolved;
|
|
37
|
+
const text = await fs.readFile(filePath, "utf-8");
|
|
38
|
+
return { text, source: `file://${filePath}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async readAll(p: string): Promise<string[]> {
|
|
42
|
+
const norm = p.startsWith("file://") ? new URL(p).pathname : p;
|
|
43
|
+
const resolved = path.resolve(norm);
|
|
44
|
+
const stat = await fs.stat(resolved);
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
const entries = await fs.readdir(resolved);
|
|
47
|
+
return entries
|
|
48
|
+
.filter((e) => e.endsWith(".yaml") || e.endsWith(".yml"))
|
|
49
|
+
.map((e) => `file://${path.join(resolved, e)}`);
|
|
50
|
+
}
|
|
51
|
+
return [`file://${resolved}`];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolveRelative(base: string, relative: string): string {
|
|
55
|
+
const basePath = base.startsWith("file://") ? new URL(base).pathname : base;
|
|
56
|
+
const baseDir = basePath.endsWith("/") ? basePath : path.dirname(basePath);
|
|
57
|
+
return `file://${path.resolve(baseDir, relative)}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function matchesDiagnostic(diag: AnalysisDiagnostic, expected: ExpectError): boolean {
|
|
62
|
+
if (expected.code && diag.code !== expected.code) return false;
|
|
63
|
+
if (expected.message && !diag.message.includes(expected.message)) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function create(
|
|
68
|
+
manifest: ManifestAssertManifest,
|
|
69
|
+
ctx: ResourceContext,
|
|
70
|
+
): Promise<Runnable> {
|
|
71
|
+
return {
|
|
72
|
+
run: async () => {
|
|
73
|
+
const useColor = (ctx.stderr as any).isTTY ?? false;
|
|
74
|
+
const c = (code: string, text: string) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
75
|
+
const bold = (t: string) => c("1", t);
|
|
76
|
+
const red = (t: string) => c("31", t);
|
|
77
|
+
const green = (t: string) => c("32", t);
|
|
78
|
+
const dim = (t: string) => c("2", t);
|
|
79
|
+
|
|
80
|
+
const name = manifest.metadata.name;
|
|
81
|
+
const loader = new Loader([new LocalFileSource()]);
|
|
82
|
+
const analyzer = new StaticAnalyzer();
|
|
83
|
+
|
|
84
|
+
const resolvedUrl = new URL(manifest.source, ctx.moduleContext.source).toString();
|
|
85
|
+
let manifests;
|
|
86
|
+
try {
|
|
87
|
+
const graph = await loader.loadGraph(resolvedUrl);
|
|
88
|
+
if (graph.errors.length > 0) throw graph.errors[0].error;
|
|
89
|
+
manifests = flattenForAnalyzer(graph);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
92
|
+
if (manifest.expect.loadError) {
|
|
93
|
+
if (errMsg.includes(manifest.expect.loadError)) {
|
|
94
|
+
ctx.stdout.write(
|
|
95
|
+
bold(green(`Assert.Manifest.${name}: assertion passed`)) +
|
|
96
|
+
"\n " + green("✓") + " " + dim(`load error: ${errMsg}`) + "\n",
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
ctx.stderr.write(
|
|
100
|
+
bold(red(`Assert.Manifest.${name}: assertion failed`)) +
|
|
101
|
+
"\n " + red("✗") + ` expected load error containing "${manifest.expect.loadError}"` +
|
|
102
|
+
"\n " + dim(`actual: ${errMsg}`) + "\n",
|
|
103
|
+
);
|
|
104
|
+
ctx.requestExit(1);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
ctx.stderr.write(
|
|
109
|
+
bold(red(`Assert.Manifest.${name}: failed to load "${manifest.source}"`)) +
|
|
110
|
+
"\n " + errMsg + "\n",
|
|
111
|
+
);
|
|
112
|
+
ctx.requestExit(1);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (manifest.expect.loadError) {
|
|
117
|
+
ctx.stderr.write(
|
|
118
|
+
bold(red(`Assert.Manifest.${name}: assertion failed`)) +
|
|
119
|
+
"\n " + red("✗") + ` expected load error containing "${manifest.expect.loadError}" but manifest loaded successfully\n`,
|
|
120
|
+
);
|
|
121
|
+
ctx.requestExit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diagnostics = analyzer.analyze(manifests);
|
|
126
|
+
const errors = diagnostics.filter((d) => d.severity === 1); // DiagnosticSeverity.Error = 1
|
|
127
|
+
const warnings = diagnostics.filter((d) => d.severity === 2); // DiagnosticSeverity.Warning = 2
|
|
128
|
+
const expectedErrors = manifest.expect.errors ?? [];
|
|
129
|
+
const expectedWarnings = manifest.expect.warnings ?? [];
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
const matched: string[] = [];
|
|
132
|
+
|
|
133
|
+
if (expectedErrors.length === 0) {
|
|
134
|
+
// Expect zero errors — any error is a failure
|
|
135
|
+
if (errors.length > 0) {
|
|
136
|
+
for (const d of errors) {
|
|
137
|
+
failures.push(`unexpected error: [${d.code}] ${d.message}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
matched.push("no errors");
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
for (const expected of expectedErrors) {
|
|
144
|
+
const match = errors.find((d) => matchesDiagnostic(d, expected));
|
|
145
|
+
if (match) {
|
|
146
|
+
matched.push(
|
|
147
|
+
`${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`,
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
failures.push(
|
|
151
|
+
`expected error ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Warnings are checked only when the caller declares expect.warnings. Unexpected
|
|
158
|
+
// warnings are not failures (unlike errors) — warnings are advisory and may exist
|
|
159
|
+
// on manifests that are otherwise valid. When expect.warnings is present, every
|
|
160
|
+
// listed warning must be found; extras are ignored.
|
|
161
|
+
if (expectedWarnings.length > 0) {
|
|
162
|
+
for (const expected of expectedWarnings) {
|
|
163
|
+
const match = warnings.find((d) => matchesDiagnostic(d, expected));
|
|
164
|
+
if (match) {
|
|
165
|
+
matched.push(
|
|
166
|
+
`warning ${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`,
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
failures.push(
|
|
170
|
+
`expected warning ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const passedLines = matched.map((m) => ` ${green("✓")} ${dim(m)}\n`).join("");
|
|
177
|
+
if (failures.length > 0) {
|
|
178
|
+
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
179
|
+
const actualLines =
|
|
180
|
+
errors.length > 0 || warnings.length > 0
|
|
181
|
+
? ` ${dim("actual diagnostics:")}\n` +
|
|
182
|
+
[...errors, ...warnings]
|
|
183
|
+
.map((d) => ` ${dim(`[${d.code}] ${d.message}`)}\n`)
|
|
184
|
+
.join("")
|
|
185
|
+
: ` ${dim("no diagnostics produced")}\n`;
|
|
186
|
+
ctx.stderr.write(
|
|
187
|
+
bold(red(`Assert.Manifest.${name}: assertion failed`)) + "\n" +
|
|
188
|
+
passedLines + failedLines + actualLines,
|
|
189
|
+
);
|
|
190
|
+
ctx.requestExit(1);
|
|
191
|
+
} else {
|
|
192
|
+
ctx.stdout.write(
|
|
193
|
+
bold(green(`Assert.Manifest.${name}: assertion passed`)) + "\n" + passedLines,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/matches.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { InvokeError, ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { Static, Type } from "@sinclair/typebox";
|
|
3
|
+
import { createColors } from "./colors.js";
|
|
4
|
+
|
|
5
|
+
export const schema = Type.Object({
|
|
6
|
+
metadata: Type.Object({
|
|
7
|
+
name: Type.String(),
|
|
8
|
+
}),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
type AssertManifest = Static<typeof schema>;
|
|
12
|
+
|
|
13
|
+
interface MatchesInput {
|
|
14
|
+
actual: unknown;
|
|
15
|
+
pattern: string;
|
|
16
|
+
flags?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function create(manifest: AssertManifest, ctx: ResourceContext) {
|
|
20
|
+
const { bold, red, green, dim } = createColors(ctx);
|
|
21
|
+
const name = manifest.metadata.name;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
invoke: (input: MatchesInput) => {
|
|
25
|
+
const { actual, pattern, flags } = input ?? ({} as MatchesInput);
|
|
26
|
+
|
|
27
|
+
if (typeof pattern !== "string") {
|
|
28
|
+
throw new InvokeError(
|
|
29
|
+
"ERR_INVALID_CONFIG",
|
|
30
|
+
`Assert.Matches "${name}": 'pattern' must be a string`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
let regex: RegExp;
|
|
34
|
+
try {
|
|
35
|
+
regex = new RegExp(pattern, flags ?? "");
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new InvokeError(
|
|
38
|
+
"ERR_INVALID_CONFIG",
|
|
39
|
+
`Assert.Matches "${name}": invalid pattern — ${err instanceof Error ? err.message : String(err)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof actual !== "string") {
|
|
44
|
+
const message = `actual must be a string; got ${typeof actual}`;
|
|
45
|
+
ctx.stderr.write(
|
|
46
|
+
bold(red(`Assert.Matches.${name}: assertion failed`)) +
|
|
47
|
+
"\n" +
|
|
48
|
+
` ${red("✗")} ${message}\n`,
|
|
49
|
+
);
|
|
50
|
+
throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Matches "${name}": ${message}`);
|
|
51
|
+
}
|
|
52
|
+
if (regex.test(actual)) {
|
|
53
|
+
ctx.stdout.write(
|
|
54
|
+
bold(green(`Assert.Matches.${name}: assertion passed`)) +
|
|
55
|
+
"\n" +
|
|
56
|
+
` ${green("✓")} ${dim(JSON.stringify(actual))} ${dim("~")} ${dim(regex.toString())}\n`,
|
|
57
|
+
);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const message = `${JSON.stringify(actual)} does not match ${regex.toString()}`;
|
|
61
|
+
ctx.stderr.write(
|
|
62
|
+
bold(red(`Assert.Matches.${name}: assertion failed`)) +
|
|
63
|
+
"\n" +
|
|
64
|
+
` ${red("✗")} ${message}\n`,
|
|
65
|
+
);
|
|
66
|
+
throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Matches "${name}": ${message}`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 resourcesToCheck = manifest.resources ?? {};
|
|
54
|
+
const failures: string[] = [];
|
|
55
|
+
const passed: string[] = [];
|
|
56
|
+
const { resources } = ctx.moduleContext;
|
|
57
|
+
for (const [alias, expected] of Object.entries(resourcesToCheck)) {
|
|
58
|
+
if (!ctx.moduleContext.hasImport(alias)) {
|
|
59
|
+
failures.push(`Import alias '${alias}' not found in declaring module`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const snap = (resources[alias] as any) ?? {};
|
|
64
|
+
const path = `resources.${alias}`;
|
|
65
|
+
|
|
66
|
+
for (const [key, expectedValue] of Object.entries(expected.variables ?? {})) {
|
|
67
|
+
const actual = snap?.variables?.[key];
|
|
68
|
+
if (deepEqual(actual, expectedValue)) {
|
|
69
|
+
passed.push(`${path}.variables.${key}`);
|
|
70
|
+
} else {
|
|
71
|
+
failures.push(`${path}.variables.${key}: expected ${yellow(JSON.stringify(expectedValue))}, got ${red(JSON.stringify(actual))}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const [key, expectedValue] of Object.entries(expected.secrets ?? {})) {
|
|
76
|
+
const actual = snap?.secrets?.[key];
|
|
77
|
+
if (deepEqual(actual, expectedValue)) {
|
|
78
|
+
passed.push(`${path}.secrets.${key}`);
|
|
79
|
+
} else {
|
|
80
|
+
failures.push(`${path}.secrets.${key}: ${dim("value mismatch")}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const name = manifest.metadata.name;
|
|
86
|
+
const passedLines = passed.map((p) => ` ${green("✓")} ${dim(p)}\n`).join("");
|
|
87
|
+
if (failures.length > 0) {
|
|
88
|
+
const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
|
|
89
|
+
ctx.stderr.write(bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n" + passedLines + failedLines);
|
|
90
|
+
ctx.requestExit(1);
|
|
91
|
+
} else {
|
|
92
|
+
ctx.stdout.write(bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n" + passedLines);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
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
|
+
}
|