@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.
@@ -0,0 +1,153 @@
1
+ import { DEFAULT_MANIFEST_FILENAME, Loader, StaticAnalyzer, flattenForAnalyzer } from "@telorun/analyzer";
2
+ import * as fs from "fs/promises";
3
+ import * as path from "path";
4
+ class LocalFileSource {
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 LocalFileSource()]);
56
+ const analyzer = new StaticAnalyzer();
57
+ const resolvedUrl = new URL(manifest.source, ctx.moduleContext.source).toString();
58
+ let manifests;
59
+ try {
60
+ const graph = await loader.loadGraph(resolvedUrl);
61
+ if (graph.errors.length > 0)
62
+ throw graph.errors[0].error;
63
+ manifests = flattenForAnalyzer(graph);
64
+ }
65
+ catch (err) {
66
+ const errMsg = err instanceof Error ? err.message : String(err);
67
+ if (manifest.expect.loadError) {
68
+ if (errMsg.includes(manifest.expect.loadError)) {
69
+ ctx.stdout.write(bold(green(`Assert.Manifest.${name}: assertion passed`)) +
70
+ "\n " + green("✓") + " " + dim(`load error: ${errMsg}`) + "\n");
71
+ }
72
+ else {
73
+ ctx.stderr.write(bold(red(`Assert.Manifest.${name}: assertion failed`)) +
74
+ "\n " + red("✗") + ` expected load error containing "${manifest.expect.loadError}"` +
75
+ "\n " + dim(`actual: ${errMsg}`) + "\n");
76
+ ctx.requestExit(1);
77
+ }
78
+ return;
79
+ }
80
+ ctx.stderr.write(bold(red(`Assert.Manifest.${name}: failed to load "${manifest.source}"`)) +
81
+ "\n " + errMsg + "\n");
82
+ ctx.requestExit(1);
83
+ return;
84
+ }
85
+ if (manifest.expect.loadError) {
86
+ ctx.stderr.write(bold(red(`Assert.Manifest.${name}: assertion failed`)) +
87
+ "\n " + red("✗") + ` expected load error containing "${manifest.expect.loadError}" but manifest loaded successfully\n`);
88
+ ctx.requestExit(1);
89
+ return;
90
+ }
91
+ const diagnostics = analyzer.analyze(manifests);
92
+ const errors = diagnostics.filter((d) => d.severity === 1); // DiagnosticSeverity.Error = 1
93
+ const warnings = diagnostics.filter((d) => d.severity === 2); // DiagnosticSeverity.Warning = 2
94
+ const expectedErrors = manifest.expect.errors ?? [];
95
+ const expectedWarnings = manifest.expect.warnings ?? [];
96
+ const failures = [];
97
+ const matched = [];
98
+ if (expectedErrors.length === 0) {
99
+ // Expect zero errors — any error is a failure
100
+ if (errors.length > 0) {
101
+ for (const d of errors) {
102
+ failures.push(`unexpected error: [${d.code}] ${d.message}`);
103
+ }
104
+ }
105
+ else {
106
+ matched.push("no errors");
107
+ }
108
+ }
109
+ else {
110
+ for (const expected of expectedErrors) {
111
+ const match = errors.find((d) => matchesDiagnostic(d, expected));
112
+ if (match) {
113
+ matched.push(`${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`);
114
+ }
115
+ else {
116
+ failures.push(`expected error ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`);
117
+ }
118
+ }
119
+ }
120
+ // Warnings are checked only when the caller declares expect.warnings. Unexpected
121
+ // warnings are not failures (unlike errors) — warnings are advisory and may exist
122
+ // on manifests that are otherwise valid. When expect.warnings is present, every
123
+ // listed warning must be found; extras are ignored.
124
+ if (expectedWarnings.length > 0) {
125
+ for (const expected of expectedWarnings) {
126
+ const match = warnings.find((d) => matchesDiagnostic(d, expected));
127
+ if (match) {
128
+ matched.push(`warning ${expected.code ?? "*"}${expected.message ? ` (${expected.message})` : ""}`);
129
+ }
130
+ else {
131
+ failures.push(`expected warning ${expected.code ?? "*"}${expected.message ? ` containing "${expected.message}"` : ""} — not found`);
132
+ }
133
+ }
134
+ }
135
+ const passedLines = matched.map((m) => ` ${green("✓")} ${dim(m)}\n`).join("");
136
+ if (failures.length > 0) {
137
+ const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
138
+ const actualLines = errors.length > 0 || warnings.length > 0
139
+ ? ` ${dim("actual diagnostics:")}\n` +
140
+ [...errors, ...warnings]
141
+ .map((d) => ` ${dim(`[${d.code}] ${d.message}`)}\n`)
142
+ .join("")
143
+ : ` ${dim("no diagnostics produced")}\n`;
144
+ ctx.stderr.write(bold(red(`Assert.Manifest.${name}: assertion failed`)) + "\n" +
145
+ passedLines + failedLines + actualLines);
146
+ ctx.requestExit(1);
147
+ }
148
+ else {
149
+ ctx.stdout.write(bold(green(`Assert.Manifest.${name}: assertion passed`)) + "\n" + passedLines);
150
+ }
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,17 @@
1
+ import { ResourceContext } from "@telorun/sdk";
2
+ import { Static } from "@sinclair/typebox";
3
+ export declare const schema: import("@sinclair/typebox").TObject<{
4
+ metadata: import("@sinclair/typebox").TObject<{
5
+ name: import("@sinclair/typebox").TString;
6
+ }>;
7
+ }>;
8
+ type AssertManifest = Static<typeof schema>;
9
+ interface MatchesInput {
10
+ actual: unknown;
11
+ pattern: string;
12
+ flags?: string;
13
+ }
14
+ export declare function create(manifest: AssertManifest, ctx: ResourceContext): Promise<{
15
+ invoke: (input: MatchesInput) => boolean;
16
+ }>;
17
+ export {};
@@ -0,0 +1,45 @@
1
+ import { InvokeError } from "@telorun/sdk";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { createColors } from "./colors.js";
4
+ export const schema = Type.Object({
5
+ metadata: Type.Object({
6
+ name: Type.String(),
7
+ }),
8
+ });
9
+ export async function create(manifest, ctx) {
10
+ const { bold, red, green, dim } = createColors(ctx);
11
+ const name = manifest.metadata.name;
12
+ return {
13
+ invoke: (input) => {
14
+ const { actual, pattern, flags } = input ?? {};
15
+ if (typeof pattern !== "string") {
16
+ throw new InvokeError("ERR_INVALID_CONFIG", `Assert.Matches "${name}": 'pattern' must be a string`);
17
+ }
18
+ let regex;
19
+ try {
20
+ regex = new RegExp(pattern, flags ?? "");
21
+ }
22
+ catch (err) {
23
+ throw new InvokeError("ERR_INVALID_CONFIG", `Assert.Matches "${name}": invalid pattern — ${err instanceof Error ? err.message : String(err)}`);
24
+ }
25
+ if (typeof actual !== "string") {
26
+ const message = `actual must be a string; got ${typeof actual}`;
27
+ ctx.stderr.write(bold(red(`Assert.Matches.${name}: assertion failed`)) +
28
+ "\n" +
29
+ ` ${red("✗")} ${message}\n`);
30
+ throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Matches "${name}": ${message}`);
31
+ }
32
+ if (regex.test(actual)) {
33
+ ctx.stdout.write(bold(green(`Assert.Matches.${name}: assertion passed`)) +
34
+ "\n" +
35
+ ` ${green("✓")} ${dim(JSON.stringify(actual))} ${dim("~")} ${dim(regex.toString())}\n`);
36
+ return true;
37
+ }
38
+ const message = `${JSON.stringify(actual)} does not match ${regex.toString()}`;
39
+ ctx.stderr.write(bold(red(`Assert.Matches.${name}: assertion failed`)) +
40
+ "\n" +
41
+ ` ${red("✗")} ${message}\n`);
42
+ throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Matches "${name}": ${message}`);
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,17 @@
1
+ import { Static } from "@sinclair/typebox";
2
+ import { ResourceContext, Runnable } from "@telorun/sdk";
3
+ export declare const schema: import("@sinclair/typebox").TObject<{
4
+ metadata: import("@sinclair/typebox").TObject<{
5
+ name: import("@sinclair/typebox").TString;
6
+ module: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ }>;
8
+ resources: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
9
+ variables: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
10
+ secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
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>>;
14
+ }>;
15
+ type ModuleContextManifest = Static<typeof schema>;
16
+ export declare function create(manifest: ModuleContextManifest, ctx: ResourceContext): Promise<Runnable>;
17
+ export {};
@@ -0,0 +1,89 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ const ImportEntry = Type.Object({
3
+ variables: Type.Optional(Type.Record(Type.String(), Type.Any())),
4
+ secrets: Type.Optional(Type.Record(Type.String(), Type.Any())),
5
+ });
6
+ export const schema = Type.Object({
7
+ metadata: Type.Object({
8
+ name: Type.String(),
9
+ module: Type.Optional(Type.String()),
10
+ }),
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())),
14
+ });
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
+ }
26
+ function deepEqual(a, b) {
27
+ if (a === b)
28
+ return true;
29
+ if (a === null || b === null)
30
+ return false;
31
+ if (typeof a !== "object" || typeof b !== "object")
32
+ return false;
33
+ const aKeys = Object.keys(a);
34
+ const bKeys = Object.keys(b);
35
+ if (aKeys.length !== bKeys.length)
36
+ return false;
37
+ for (const key of aKeys) {
38
+ if (!deepEqual(a[key], b[key]))
39
+ return false;
40
+ }
41
+ return true;
42
+ }
43
+ export async function create(manifest, ctx) {
44
+ return {
45
+ run: async () => {
46
+ const { bold, red, green, yellow, dim } = createColors(ctx.stderr);
47
+ const resourcesToCheck = manifest.resources ?? {};
48
+ const failures = [];
49
+ const passed = [];
50
+ const { resources } = ctx.moduleContext;
51
+ for (const [alias, expected] of Object.entries(resourcesToCheck)) {
52
+ if (!ctx.moduleContext.hasImport(alias)) {
53
+ failures.push(`Import alias '${alias}' not found in declaring module`);
54
+ continue;
55
+ }
56
+ const snap = resources[alias] ?? {};
57
+ const path = `resources.${alias}`;
58
+ for (const [key, expectedValue] of Object.entries(expected.variables ?? {})) {
59
+ const actual = snap?.variables?.[key];
60
+ if (deepEqual(actual, expectedValue)) {
61
+ passed.push(`${path}.variables.${key}`);
62
+ }
63
+ else {
64
+ failures.push(`${path}.variables.${key}: expected ${yellow(JSON.stringify(expectedValue))}, got ${red(JSON.stringify(actual))}`);
65
+ }
66
+ }
67
+ for (const [key, expectedValue] of Object.entries(expected.secrets ?? {})) {
68
+ const actual = snap?.secrets?.[key];
69
+ if (deepEqual(actual, expectedValue)) {
70
+ passed.push(`${path}.secrets.${key}`);
71
+ }
72
+ else {
73
+ failures.push(`${path}.secrets.${key}: ${dim("value mismatch")}`);
74
+ }
75
+ }
76
+ }
77
+ const name = manifest.metadata.name;
78
+ const passedLines = passed.map((p) => ` ${green("✓")} ${dim(p)}\n`).join("");
79
+ if (failures.length > 0) {
80
+ const failedLines = failures.map((f) => ` ${red("✗")} ${f}\n`).join("");
81
+ ctx.stderr.write(bold(red(`Assert.ModuleContext.${name}: assertion failed`)) + "\n" + passedLines + failedLines);
82
+ ctx.requestExit(1);
83
+ }
84
+ else {
85
+ ctx.stdout.write(bold(green(`Assert.ModuleContext.${name}: assertion passed`)) + "\n" + passedLines);
86
+ }
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,15 @@
1
+ import { ResourceContext } from "@telorun/sdk";
2
+ import { Static } from "@sinclair/typebox";
3
+ export declare const schema: import("@sinclair/typebox").TObject<{
4
+ metadata: import("@sinclair/typebox").TObject<{
5
+ name: import("@sinclair/typebox").TString;
6
+ }>;
7
+ schema: import("@sinclair/typebox").TObject<{
8
+ type: import("@sinclair/typebox").TString;
9
+ }>;
10
+ }>;
11
+ type AssertManifest = Static<typeof schema>;
12
+ export declare function create(manifest: AssertManifest, ctx: ResourceContext): Promise<{
13
+ invoke: (data: any) => boolean;
14
+ }>;
15
+ export {};
package/dist/schema.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ export const schema = Type.Object({
3
+ metadata: Type.Object({
4
+ name: Type.String(),
5
+ }),
6
+ schema: Type.Object({
7
+ type: Type.String(),
8
+ }),
9
+ });
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
+ const validator = ctx.createSchemaValidator(manifest.schema);
18
+ const name = manifest.metadata.name;
19
+ return {
20
+ invoke: (data) => {
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
+ }
30
+ },
31
+ };
32
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@telorun/assert",
3
+ "version": "0.1.0",
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
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ "./schema": {
24
+ "types": "./dist/schema.d.ts",
25
+ "bun": "./src/schema.ts",
26
+ "import": "./dist/schema.js"
27
+ },
28
+ "./events": {
29
+ "types": "./dist/events.d.ts",
30
+ "bun": "./src/events.ts",
31
+ "import": "./dist/events.js"
32
+ },
33
+ "./module-context": {
34
+ "types": "./dist/module-context.d.ts",
35
+ "bun": "./src/module-context.ts",
36
+ "import": "./dist/module-context.js"
37
+ },
38
+ "./manifest": {
39
+ "types": "./dist/manifest.d.ts",
40
+ "bun": "./src/manifest.ts",
41
+ "import": "./dist/manifest.js"
42
+ },
43
+ "./equals": {
44
+ "types": "./dist/equals.d.ts",
45
+ "bun": "./src/equals.ts",
46
+ "import": "./dist/equals.js"
47
+ },
48
+ "./matches": {
49
+ "types": "./dist/matches.d.ts",
50
+ "bun": "./src/matches.ts",
51
+ "import": "./dist/matches.js"
52
+ },
53
+ "./contains": {
54
+ "types": "./dist/contains.d.ts",
55
+ "bun": "./src/contains.ts",
56
+ "import": "./dist/contains.js"
57
+ }
58
+ },
59
+ "files": [
60
+ "dist",
61
+ "src/**"
62
+ ],
63
+ "dependencies": {
64
+ "@sinclair/typebox": "^0.34.48",
65
+ "@telorun/analyzer": "0.12.0"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "^20.0.0",
69
+ "typescript": "^5.0.0"
70
+ },
71
+ "peerDependencies": {
72
+ "@telorun/sdk": "0.12.0"
73
+ },
74
+ "scripts": {
75
+ "build": "tsc -p tsconfig.lib.json"
76
+ }
77
+ }
package/src/colors.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ResourceContext } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Build the small ANSI color helpers used by every assert kind. Returns
5
+ * pass-through (uncolored) helpers when stderr isn't a TTY so piped /
6
+ * redirected output stays clean.
7
+ */
8
+ export function createColors(ctx: ResourceContext) {
9
+ const useColor = (ctx.stderr as { isTTY?: boolean }).isTTY ?? false;
10
+ const c = (code: string, text: string) =>
11
+ useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
12
+ return {
13
+ bold: (t: string) => c("1", t),
14
+ red: (t: string) => c("31", t),
15
+ green: (t: string) => c("32", t),
16
+ dim: (t: string) => c("2", t),
17
+ };
18
+ }
@@ -0,0 +1,74 @@
1
+ import { InvokeError, ResourceContext } from "@telorun/sdk";
2
+ import { Static, Type } from "@sinclair/typebox";
3
+ import { createColors } from "./colors.js";
4
+ import { deepEquals } from "./deep-equals.js";
5
+
6
+ export const schema = Type.Object({
7
+ metadata: Type.Object({
8
+ name: Type.String(),
9
+ }),
10
+ });
11
+
12
+ type AssertManifest = Static<typeof schema>;
13
+
14
+ interface ContainsInput {
15
+ actual: unknown;
16
+ value: unknown;
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: ContainsInput) => {
25
+ const { actual, value } = input ?? ({} as ContainsInput);
26
+
27
+ let ok = false;
28
+ let kind = "";
29
+ if (typeof actual === "string") {
30
+ if (typeof value !== "string") {
31
+ const message = `actual is a string but value is ${typeof value}; expected substring`;
32
+ ctx.stderr.write(
33
+ bold(red(`Assert.Contains.${name}: assertion failed`)) +
34
+ "\n" +
35
+ ` ${red("✗")} ${message}\n`,
36
+ );
37
+ throw new InvokeError(
38
+ "ERR_ASSERTION_FAILED",
39
+ `Assert.Contains "${name}": ${message}`,
40
+ );
41
+ }
42
+ kind = "substring";
43
+ ok = actual.includes(value);
44
+ } else if (Array.isArray(actual)) {
45
+ kind = "element";
46
+ ok = actual.some((item) => deepEquals(item, value));
47
+ } else {
48
+ const message = `actual must be string or array; got ${typeof actual}`;
49
+ ctx.stderr.write(
50
+ bold(red(`Assert.Contains.${name}: assertion failed`)) +
51
+ "\n" +
52
+ ` ${red("✗")} ${message}\n`,
53
+ );
54
+ throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Contains "${name}": ${message}`);
55
+ }
56
+
57
+ if (ok) {
58
+ ctx.stdout.write(
59
+ bold(green(`Assert.Contains.${name}: assertion passed`)) +
60
+ "\n" +
61
+ ` ${green("✓")} ${dim(JSON.stringify(actual))} ${dim("⊇")} ${dim(JSON.stringify(value))}\n`,
62
+ );
63
+ return true;
64
+ }
65
+ const message = `${JSON.stringify(actual)} does not contain ${kind} ${JSON.stringify(value)}`;
66
+ ctx.stderr.write(
67
+ bold(red(`Assert.Contains.${name}: assertion failed`)) +
68
+ "\n" +
69
+ ` ${red("✗")} ${message}\n`,
70
+ );
71
+ throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Contains "${name}": ${message}`);
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deep equality for the JSON-shaped values Telo stages typically pass
3
+ * around: primitives, plain objects (proto === Object.prototype or null),
4
+ * and arrays. Non-plain objects (Date, Map, Set, RegExp, class instances)
5
+ * are NOT structurally compared — only `Object.is`-equal instances pass.
6
+ *
7
+ * This is intentional: structurally comparing the empty `Object.keys()`
8
+ * of two distinct `new Date(…)` instances would silently return true,
9
+ * letting `Assert.Equals` pass for values that are clearly different.
10
+ * If a consumer genuinely needs Date / Map / Set equality, they should
11
+ * serialize first (e.g. `date.toISOString()`) and compare the strings.
12
+ */
13
+ export function deepEquals(a: unknown, b: unknown): boolean {
14
+ if (Object.is(a, b)) return true;
15
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
16
+
17
+ if (Array.isArray(a) || Array.isArray(b)) {
18
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
19
+ for (let i = 0; i < a.length; i++) {
20
+ if (!deepEquals(a[i], b[i])) return false;
21
+ }
22
+ return true;
23
+ }
24
+
25
+ // Refuse structural compare for non-plain objects. Identity was already
26
+ // checked at the top; reaching here with a non-plain object means a !== b
27
+ // and we cannot meaningfully recurse into "fields" — empty keys would
28
+ // produce false positives.
29
+ if (!isPlainObject(a) || !isPlainObject(b)) return false;
30
+
31
+ const ao = a as Record<string, unknown>;
32
+ const bo = b as Record<string, unknown>;
33
+ const aKeys = Object.keys(ao);
34
+ const bKeys = Object.keys(bo);
35
+ if (aKeys.length !== bKeys.length) return false;
36
+ for (const k of aKeys) {
37
+ if (!Object.prototype.hasOwnProperty.call(bo, k) || !deepEquals(ao[k], bo[k])) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ function isPlainObject(v: object): boolean {
43
+ const proto = Object.getPrototypeOf(v);
44
+ return proto === Object.prototype || proto === null;
45
+ }
package/src/equals.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { InvokeError, ResourceContext } from "@telorun/sdk";
2
+ import { Static, Type } from "@sinclair/typebox";
3
+ import { createColors } from "./colors.js";
4
+ import { deepEquals } from "./deep-equals.js";
5
+
6
+ export const schema = Type.Object({
7
+ metadata: Type.Object({
8
+ name: Type.String(),
9
+ }),
10
+ });
11
+
12
+ type AssertManifest = Static<typeof schema>;
13
+
14
+ interface EqualsInput {
15
+ actual: unknown;
16
+ expected: unknown;
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: EqualsInput) => {
25
+ const { actual, expected } = input ?? ({} as EqualsInput);
26
+ if (deepEquals(actual, expected)) {
27
+ ctx.stdout.write(
28
+ bold(green(`Assert.Equals.${name}: assertion passed`)) +
29
+ "\n" +
30
+ ` ${green("✓")} ${dim(JSON.stringify(actual))}\n`,
31
+ );
32
+ return true;
33
+ }
34
+ const message = `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
35
+ ctx.stderr.write(
36
+ bold(red(`Assert.Equals.${name}: assertion failed`)) +
37
+ "\n" +
38
+ ` ${red("✗")} ${message}\n`,
39
+ );
40
+ throw new InvokeError("ERR_ASSERTION_FAILED", `Assert.Equals "${name}": ${message}`);
41
+ },
42
+ };
43
+ }