@telorun/assert 0.1.4 → 0.1.6

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/dist/events.js CHANGED
@@ -45,67 +45,60 @@ function matchesPayload(actual, expected) {
45
45
  }
46
46
  return true;
47
47
  }
48
- const useColor = process.stderr.isTTY;
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 yellow = (t) => c("33", t);
54
- const dim = (t) => c("2", t);
55
- function buildReport(name, captured, expect) {
56
- const results = [];
57
- let pos = 0;
58
- for (const entry of expect) {
59
- let found = false;
60
- while (pos < captured.length) {
61
- const ev = captured[pos++];
62
- if (matchesPattern(entry.event, ev.name)) {
63
- if (!entry.payload || matchesPayload(ev.payload, entry.payload)) {
64
- results.push({ status: "matched", entry, actual: ev });
65
- }
66
- else {
67
- results.push({ status: "payload-mismatch", entry, actual: ev });
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
- found = true;
70
- break;
73
+ }
74
+ if (!found) {
75
+ results.push({ status: "not-found", entry });
71
76
  }
72
77
  }
73
- if (!found) {
74
- results.push({ status: "not-found", entry });
75
- }
76
- }
77
- const failures = results.filter((r) => r.status !== "matched");
78
- // if (failures.length === 0) {
79
- // return null;
80
- // }
81
- let report = bold(failures.length > 0
82
- ? red(`Assert.Events.${name}: assertion failed`)
83
- : green(`Assert.Events.${name}: assertion passed`)) + "\n";
84
- for (const result of results) {
85
- if (result.status === "matched") {
86
- report += ` ${green("✓")} ${dim(result.actual.name)}\n`;
87
- }
88
- else if (result.status === "not-found") {
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
- else if (result.status === "payload-mismatch") {
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
- process.stdout.write(report.report);
112
+ ctx.stdout.write(report.report);
120
113
  }
121
114
  else {
122
- process.stderr.write(report.report);
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 {};
@@ -0,0 +1,111 @@
1
+ import { 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, "module.yaml") : 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
+ }
@@ -5,10 +5,12 @@ export declare const schema: import("@sinclair/typebox").TObject<{
5
5
  name: import("@sinclair/typebox").TString;
6
6
  module: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
7
  }>;
8
- imports: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
8
+ resources: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
9
9
  variables: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
10
10
  secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
11
11
  }>>>;
12
+ variables: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
13
+ secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
12
14
  }>;
13
15
  type ModuleContextManifest = Static<typeof schema>;
14
16
  export declare function create(manifest: ModuleContextManifest, ctx: ResourceContext): Promise<Runnable>;
@@ -8,15 +8,21 @@ export const schema = Type.Object({
8
8
  name: Type.String(),
9
9
  module: Type.Optional(Type.String()),
10
10
  }),
11
- imports: Type.Optional(Type.Record(Type.String(), ImportEntry)),
11
+ resources: Type.Optional(Type.Record(Type.String(), ImportEntry)),
12
+ variables: Type.Optional(Type.Record(Type.String(), Type.Any())),
13
+ secrets: Type.Optional(Type.Record(Type.String(), Type.Any())),
12
14
  });
13
- const useColor = process.stderr.isTTY;
14
- const c = (code, text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
15
- const bold = (t) => c("1", t);
16
- const red = (t) => c("31", t);
17
- const green = (t) => c("32", t);
18
- const yellow = (t) => c("33", t);
19
- const dim = (t) => c("2", t);
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
+ }
20
26
  function deepEqual(a, b) {
21
27
  if (a === b)
22
28
  return true;
@@ -37,57 +43,47 @@ function deepEqual(a, b) {
37
43
  export async function create(manifest, ctx) {
38
44
  return {
39
45
  run: async () => {
46
+ const { bold, red, green, yellow, dim } = createColors(ctx.stderr);
40
47
  const declaringModule = manifest.metadata.module ?? "default";
41
- const importsToCheck = manifest.imports ?? {};
48
+ const resourcesToCheck = manifest.resources ?? {};
42
49
  const failures = [];
43
50
  const passed = [];
44
- for (const [alias, expected] of Object.entries(importsToCheck)) {
45
- const realModule = ctx.resolveModuleAlias(declaringModule, alias);
46
- if (!realModule) {
51
+ const { resources } = ctx.moduleContext;
52
+ for (const [alias, expected] of Object.entries(resourcesToCheck)) {
53
+ if (!ctx.resolveModuleAlias(declaringModule, alias)) {
47
54
  failures.push(`Import alias '${alias}' not found in module '${declaringModule}'`);
48
55
  continue;
49
56
  }
50
- const moduleCtx = ctx.moduleContext;
51
- const importSnap = moduleCtx.resources[alias] ?? {};
52
- const expectedVariables = expected.variables ?? {};
53
- const expectedSecrets = expected.secrets ?? {};
54
- for (const [key, expectedValue] of Object.entries(expectedVariables)) {
55
- const actualValue = importSnap?.variables?.[key];
56
- if (!deepEqual(actualValue, expectedValue)) {
57
- failures.push(`imports.${alias}.variables.${key}: expected ${yellow(JSON.stringify(expectedValue))}, got ${red(JSON.stringify(actualValue))}`);
57
+ const snap = resources[alias] ?? {};
58
+ const path = `resources.${alias}`;
59
+ for (const [key, expectedValue] of Object.entries(expected.variables ?? {})) {
60
+ const actual = snap?.variables?.[key];
61
+ if (deepEqual(actual, expectedValue)) {
62
+ passed.push(`${path}.variables.${key}`);
58
63
  }
59
64
  else {
60
- passed.push(`imports.${alias}.variables.${key}`);
65
+ failures.push(`${path}.variables.${key}: expected ${yellow(JSON.stringify(expectedValue))}, got ${red(JSON.stringify(actual))}`);
61
66
  }
62
67
  }
63
- for (const [key] of Object.entries(expectedSecrets)) {
64
- const actualSecret = importSnap?.secrets?.[key];
65
- if (!deepEqual(actualSecret, expectedSecrets[key])) {
66
- failures.push(`imports.${alias}.secrets.${key}: ${dim("value mismatch")}`);
68
+ for (const [key, expectedValue] of Object.entries(expected.secrets ?? {})) {
69
+ const actual = snap?.secrets?.[key];
70
+ if (deepEqual(actual, expectedValue)) {
71
+ passed.push(`${path}.secrets.${key}`);
67
72
  }
68
73
  else {
69
- passed.push(`imports.${alias}.secrets.${key}`);
74
+ failures.push(`${path}.secrets.${key}: ${dim("value mismatch")}`);
70
75
  }
71
76
  }
72
77
  }
73
78
  const name = manifest.metadata.name;
79
+ const passedLines = passed.map((p) => ` ${green("✓")} ${dim(p)}\n`).join("");
74
80
  if (failures.length > 0) {
75
- let report = bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n";
76
- for (const p of passed) {
77
- report += ` ${green("✓")} ${dim(p)}\n`;
78
- }
79
- for (const f of failures) {
80
- report += ` ${red("✗")} ${f}\n`;
81
- }
82
- process.stderr.write(report);
81
+ const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
82
+ ctx.stderr.write(bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n" + passedLines + failedLines);
83
83
  ctx.requestExit(1);
84
84
  }
85
85
  else {
86
- let report = bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n";
87
- for (const p of passed) {
88
- report += ` ${green("✓")} ${dim(p)}\n`;
89
- }
90
- process.stdout.write(report);
86
+ ctx.stdout.write(bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n" + passedLines);
91
87
  }
92
88
  },
93
89
  };
package/dist/schema.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { ResourceContext } from "@telorun/sdk";
2
2
  import { Static } from "@sinclair/typebox";
3
3
  export declare const schema: import("@sinclair/typebox").TObject<{
4
+ metadata: import("@sinclair/typebox").TObject<{
5
+ name: import("@sinclair/typebox").TString;
6
+ }>;
4
7
  schema: import("@sinclair/typebox").TObject<{
5
8
  type: import("@sinclair/typebox").TString;
6
9
  }>;
package/dist/schema.js CHANGED
@@ -1,15 +1,32 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  export const schema = Type.Object({
3
+ metadata: Type.Object({
4
+ name: Type.String(),
5
+ }),
3
6
  schema: Type.Object({
4
7
  type: Type.String(),
5
8
  }),
6
- }, {});
9
+ });
7
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);
8
17
  const validator = ctx.createSchemaValidator(manifest.schema);
18
+ const name = manifest.metadata.name;
9
19
  return {
10
20
  invoke: (data) => {
11
- validator.validate(data);
12
- return true;
21
+ try {
22
+ validator.validate(data);
23
+ ctx.stdout.write(bold(green(`Assert.Schema.${name}: assertion passed`)) + "\n" + ` ${green("✓")} ${dim(JSON.stringify(data))}\n`);
24
+ return true;
25
+ }
26
+ catch (err) {
27
+ ctx.stderr.write(bold(red(`Assert.Schema.${name}: assertion failed`)) + "\n" + ` ${red("✗")} ${err?.message ?? String(err)}\n`);
28
+ throw err;
29
+ }
13
30
  },
14
31
  };
15
32
  }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@telorun/assert",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./schema": "./src/schema.ts",
7
7
  "./events": "./src/events.ts",
8
- "./module-context": "./src/module-context.ts"
8
+ "./module-context": "./src/module-context.ts",
9
+ "./manifest": "./src/manifest.ts"
9
10
  },
10
11
  "files": [
11
- "dist/**"
12
+ "dist",
13
+ "src/**"
12
14
  ],
13
15
  "dependencies": {
14
16
  "@sinclair/typebox": "^0.34.48",
15
- "@telorun/sdk": "0.2.5"
17
+ "@telorun/analyzer": "0.1.2",
18
+ "@telorun/sdk": "0.2.7"
16
19
  },
17
20
  "devDependencies": {
18
21
  "@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
+ }
@@ -0,0 +1,144 @@
1
+ import { 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, "module.yaml") : 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
+ }