aimyapi 1.0.8 → 1.0.10

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,185 @@
1
+ import { SandboxLog } from "./logger";
2
+ import { dequal } from "./utils";
3
+
4
+ /** Return true to pass. Returning a falsy value or throwing fails the check. */
5
+ export type ArgumentsEvaluator = (args: any[]) => any;
6
+ /** Return true to pass. Returning a falsy value or throwing fails the check. */
7
+ export type ResultEvaluator = (result: any) => any;
8
+
9
+ /**
10
+ * One step in a test sequence. Gets the logs and the current position, checks
11
+ * the entries there, and returns the position of the next unchecked entry.
12
+ * Throws TestFailed if the check fails.
13
+ */
14
+ export type TestStep = (logs: SandboxLog[], index: number) => number;
15
+
16
+ export class TestFailed extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "TestFailed";
20
+ }
21
+ }
22
+
23
+ function describeEntry(entry: SandboxLog | undefined): string {
24
+ if (!entry) return "end of logs";
25
+ const short = (v: any) => JSON.stringify(v);
26
+ if (entry.type === "trace") {
27
+ return `trace ${entry.fnName}(${entry.fnArgs.map(short).join(", ")}) -> ${short(entry.result)}`;
28
+ }
29
+ return `${entry.type} [${entry.source}] ${entry.args.map(short).join(" ")}`;
30
+ }
31
+
32
+ function nameMatches(pattern: string, name: string): boolean {
33
+ return pattern === "*" || pattern === name;
34
+ }
35
+
36
+ /** Runs an evaluator and throws TestFailed if it returns falsy or throws. */
37
+ function evaluate(evaluator: ((value: any) => any) | undefined, value: any, what: string, index: number, entry: SandboxLog) {
38
+ if (!evaluator) return;
39
+ let passed: any;
40
+ try {
41
+ passed = evaluator(value);
42
+ } catch (e: any) {
43
+ throw new TestFailed(`Log #${index}: ${what} check threw: ${e?.message ?? e}. Entry: ${describeEntry(entry)}`);
44
+ }
45
+ if (!passed) {
46
+ throw new TestFailed(`Log #${index}: ${what} check failed. Entry: ${describeEntry(entry)}`);
47
+ }
48
+ }
49
+
50
+ function checkTrace(logs: SandboxLog[], index: number, functionName: string, argumentsEvaluator?: ArgumentsEvaluator, resultEvaluator?: ResultEvaluator) {
51
+ const entry = logs[index];
52
+ if (!entry || entry.type !== "trace" || !nameMatches(functionName, entry.fnName)) {
53
+ throw new TestFailed(`Log #${index}: expected trace for "${functionName}", got ${describeEntry(entry)}`);
54
+ }
55
+ evaluate(argumentsEvaluator, entry.fnArgs, `arguments of "${entry.fnName}"`, index, entry);
56
+ evaluate(resultEvaluator, entry.result, `result of "${entry.fnName}"`, index, entry);
57
+ }
58
+
59
+ function expectEntryType(type: "log" | "error", argumentsEvaluator?: ArgumentsEvaluator): TestStep {
60
+ return (logs, index) => {
61
+ const entry = logs[index];
62
+ if (!entry || entry.type !== type) {
63
+ throw new TestFailed(`Log #${index}: expected ${type}, got ${describeEntry(entry)}`);
64
+ }
65
+ evaluate(argumentsEvaluator, entry.args, `${type} arguments`, index, entry);
66
+ return index + 1;
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Checks the logs from a sandbox run against an expected sequence of entries.
72
+ *
73
+ * @example
74
+ * test.sequence(
75
+ * test.expectTrace("getSalesData"),
76
+ * test.expectTrace("display", (args) => args[0].includes("The month with the highest sales was")),
77
+ * );
78
+ */
79
+ export class LogTester {
80
+ private source: SandboxLog[];
81
+
82
+ constructor(source: SandboxLog[]) {
83
+ this.source = source;
84
+ }
85
+
86
+ private get logs(): SandboxLog[] {
87
+ return this.source;
88
+ }
89
+
90
+ /**
91
+ * Runs the steps in order, starting at the first log entry. Throws TestFailed
92
+ * on the first failing step. Entries after the last step are ignored.
93
+ * Accepts steps as separate arguments or as one array.
94
+ */
95
+ sequence(...steps: (TestStep | TestStep[])[]): true {
96
+ const logs = this.logs;
97
+ let index = 0;
98
+ for (const step of steps.flat()) {
99
+ if (typeof step !== "function") {
100
+ throw new TestFailed(`sequence() expects test steps (e.g. test.expectTrace(...)), got ${typeof step}`);
101
+ }
102
+ index = step(logs, index);
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /** Next entry must be a trace for `functionName` ("*" for any function). */
108
+ expectTrace(functionName: string, argumentsEvaluator?: ArgumentsEvaluator, resultEvaluator?: ResultEvaluator): TestStep {
109
+ return (logs, index) => {
110
+ checkTrace(logs, index, functionName, argumentsEvaluator, resultEvaluator);
111
+ return index + 1;
112
+ };
113
+ }
114
+
115
+ /** Next entry can be anything, but there must be one. */
116
+ expectAny(): TestStep {
117
+ return (logs, index) => {
118
+ if (index >= logs.length) {
119
+ throw new TestFailed(`Log #${index}: expected any entry, got end of logs`);
120
+ }
121
+ return index + 1;
122
+ };
123
+ }
124
+
125
+ /** Skips ahead to just before the next trace for `functionName` ("*" for any). Fails if there is none. */
126
+ skipToTrace(functionName: string): TestStep {
127
+ return (logs, index) => {
128
+ for (let i = index; i < logs.length; i++) {
129
+ const entry = logs[i];
130
+ if (entry.type === "trace" && nameMatches(functionName, entry.fnName)) {
131
+ return i;
132
+ }
133
+ }
134
+ throw new TestFailed(`Log #${index}: no trace for "${functionName}" found from here to the end of the logs`);
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Consecutive traces for `functionName` ("*" for any).
140
+ * With `num` > 0, exactly that many must be next (more are left for later steps).
141
+ * With `num` 0 or undefined, takes as many as are next, including none.
142
+ * The evaluators are checked against every matched trace.
143
+ */
144
+ expectMultipleTraces(functionName: string, num?: number, argumentsEvaluator?: ArgumentsEvaluator, resultEvaluator?: ResultEvaluator): TestStep {
145
+ return (logs, index) => {
146
+ if (num && num > 0) {
147
+ for (let n = 0; n < num; n++) {
148
+ const entry = logs[index + n];
149
+ if (!entry || entry.type !== "trace" || !nameMatches(functionName, entry.fnName)) {
150
+ throw new TestFailed(`Log #${index + n}: expected ${num} traces for "${functionName}" but found ${n}. Got ${describeEntry(entry)}`);
151
+ }
152
+ checkTrace(logs, index + n, functionName, argumentsEvaluator, resultEvaluator);
153
+ }
154
+ return index + num;
155
+ }
156
+
157
+ let i = index;
158
+ while (i < logs.length) {
159
+ const entry = logs[i];
160
+ if (entry.type !== "trace" || !nameMatches(functionName, entry.fnName)) break;
161
+ checkTrace(logs, i, functionName, argumentsEvaluator, resultEvaluator);
162
+ i++;
163
+ }
164
+ return i;
165
+ };
166
+ }
167
+
168
+ /** Next entry must be a log (console.log etc). The evaluator gets the logged arguments. */
169
+ expectLog(argumentsEvaluator?: ArgumentsEvaluator): TestStep {
170
+ return expectEntryType("log", argumentsEvaluator);
171
+ }
172
+
173
+ /** Next entry must be an error. The evaluator gets the error arguments. */
174
+ expectError(argumentsEvaluator?: ArgumentsEvaluator): TestStep {
175
+ return expectEntryType("error", argumentsEvaluator);
176
+ }
177
+
178
+ /** Deep equality. Returns true if equal, otherwise throws TestFailed, so it works inside evaluators. */
179
+ equals(actual: any, expected: any): true {
180
+ if (!dequal(actual, expected)) {
181
+ throw new TestFailed(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
182
+ }
183
+ return true;
184
+ }
185
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,113 @@
1
+ export type LogSource = "sandbox" | "compiler" | "runner";
2
+ export type LogEntryType = "log" | "error" | "trace";
3
+
4
+ export type SandboxLog = {
5
+ type: "log" | "error";
6
+ args: any[];
7
+ source: LogSource
8
+ } | {
9
+ type: "trace";
10
+ fnName: string,
11
+ fnArgs: any[],
12
+ result: any
13
+ };
14
+
15
+ export class Logger {
16
+ private logs: SandboxLog[] = [];
17
+ debug: boolean = false;
18
+
19
+ constructor(debug: boolean = false) {
20
+ this.debug = debug;
21
+ }
22
+
23
+ getLogs() {
24
+ return this.logs;
25
+ }
26
+
27
+ addEntry(type: "log" | "error", args: any, source: LogSource) {
28
+ if(Array.isArray(args)) {
29
+ this.logs.push({ type, args, source });
30
+ } else {
31
+ this.logs.push({ type, args: [args], source });
32
+ }
33
+ }
34
+ log(args: any, source: LogSource) {
35
+ this.addEntry("log", args, source);
36
+ if (this.debug) {
37
+ console.log(`[${source}]:`, ...args);
38
+ }
39
+ }
40
+ trace(fnName: string, fnArgs: any[], result: any) {
41
+ //this.addEntry("trace", Logger.limitObjectForLogs(args, 5, 5), source);
42
+ this.logs.push({ type: "trace", fnName, fnArgs, result })
43
+ if (this.debug) {
44
+ console.debug(`[sandbox]: ${fnName}(${fnArgs.map(arg => JSON.stringify(Logger.limitObjectForLogs(arg))).join(",")}) -> ${JSON.stringify(Logger.limitObjectForLogs(result))}`);
45
+ }
46
+ }
47
+ error(args: any, source: LogSource) {
48
+ this.addEntry("error", args, source);
49
+
50
+ if (this.debug) {
51
+ console.error(`[${source}]:`, ...args);
52
+ }
53
+ }
54
+
55
+ clear() {
56
+ this.logs = [];
57
+ }
58
+
59
+ getPrettyLogs() {
60
+ return this.logs.map(entry => Logger.limitObjectForLogs(entry))
61
+ }
62
+
63
+ /**
64
+ * Returns a trimmed copy of `value` that is safe to log.
65
+ * - Objects/arrays nested deeper than `maxDepth` become "[Object]" / "[Array(n)]".
66
+ * - Arrays longer than `maxArrayItems` keep the first items plus a "+N more items" string.
67
+ * - Circular references become "[Circular]".
68
+ */
69
+ static limitObjectForLogs(value: any, maxDepth: number = 3, maxArrayItems: number = 5): any {
70
+ const seen = new WeakSet<object>();
71
+
72
+ const trim = (val: any, depth: number): any => {
73
+ if (val === null || typeof val !== "object") {
74
+ if (typeof val === "function") return `[Function${val.name ? `: ${val.name}` : ""}]`;
75
+ if (typeof val === "bigint") return `${val}n`;
76
+ if (typeof val === "symbol") return val.toString();
77
+ return val;
78
+ }
79
+
80
+ if (val instanceof Date) return val.toISOString();
81
+ if (val instanceof Error) {
82
+ return { name: val.name, message: val.message, stack: val.stack };
83
+ }
84
+
85
+ if (seen.has(val)) return "[Circular]";
86
+
87
+ if (depth >= maxDepth) {
88
+ return Array.isArray(val) ? `[Array(${val.length})]` : "[Object]";
89
+ }
90
+
91
+ seen.add(val);
92
+ let result: any;
93
+
94
+ if (Array.isArray(val)) {
95
+ result = val.slice(0, maxArrayItems).map((item) => trim(item, depth + 1));
96
+ if (val.length > maxArrayItems) {
97
+ result.push(`+${val.length - maxArrayItems} more items`);
98
+ }
99
+ } else {
100
+ result = {};
101
+ for (const key of Object.keys(val)) {
102
+ result[key] = trim(val[key], depth + 1);
103
+ }
104
+ }
105
+
106
+ // Only track the current path, so shared (non-circular) references still print.
107
+ seen.delete(val);
108
+ return result;
109
+ };
110
+
111
+ return trim(value, 0);
112
+ }
113
+ }