keystone-cli 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.
Files changed (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. package/src/utils/workflow-registry.ts +121 -0
@@ -0,0 +1,99 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { WorkflowDb } from './workflow-db';
5
+
6
+ describe('WorkflowDb', () => {
7
+ const dbPath = ':memory:';
8
+ let db: WorkflowDb;
9
+
10
+ beforeAll(() => {
11
+ db = new WorkflowDb(dbPath);
12
+ });
13
+
14
+ afterAll(() => {
15
+ db.close();
16
+ });
17
+
18
+ it('should create and retrieve a run', async () => {
19
+ const runId = 'run-1';
20
+ await db.createRun(runId, 'test-wf', { input: 1 });
21
+ const run = db.getRun(runId);
22
+ expect(run).toBeDefined();
23
+ expect(run?.workflow_name).toBe('test-wf');
24
+ expect(JSON.parse(run?.inputs || '{}')).toEqual({ input: 1 });
25
+ });
26
+
27
+ it('should update run status', async () => {
28
+ const runId = 'run-2';
29
+ await db.createRun(runId, 'test-wf', {});
30
+ await db.updateRunStatus(runId, 'completed', { result: 'ok' });
31
+ const run = db.getRun(runId);
32
+ expect(run?.status).toBe('completed');
33
+ expect(JSON.parse(run?.outputs || '{}')).toEqual({ result: 'ok' });
34
+ });
35
+
36
+ it('should create and list steps', async () => {
37
+ const runId = 'run-3';
38
+ await db.createRun(runId, 'test-wf', {});
39
+ const stepId = 'step-a';
40
+ await db.createStep('exec-1', runId, stepId);
41
+ await db.startStep('exec-1');
42
+ await db.completeStep('exec-1', 'success', { out: 'val' });
43
+
44
+ const steps = db.getStepsByRun(runId);
45
+ expect(steps).toHaveLength(1);
46
+ expect(steps[0].step_id).toBe(stepId);
47
+ expect(steps[0].status).toBe('success');
48
+ });
49
+
50
+ it('should handle iterations in steps', async () => {
51
+ const runId = 'run-4';
52
+ await db.createRun(runId, 'test-wf', {});
53
+ await db.createStep('exec-i0', runId, 'loop', 0);
54
+ await db.createStep('exec-i1', runId, 'loop', 1);
55
+
56
+ const step0 = db.getStepByIteration(runId, 'loop', 0);
57
+ expect(step0).toBeDefined();
58
+ expect(step0?.iteration_index).toBe(0);
59
+
60
+ const steps = db.getStepsByRun(runId);
61
+ expect(steps).toHaveLength(2);
62
+ });
63
+
64
+ it('should increment retry count', async () => {
65
+ const runId = 'run-5';
66
+ await db.createRun(runId, 'test-wf', {});
67
+ await db.createStep('exec-r', runId, 'retry-step');
68
+ await db.incrementRetry('exec-r');
69
+ await db.incrementRetry('exec-r');
70
+
71
+ const steps = db.getStepsByRun(runId);
72
+ expect(steps[0].retry_count).toBe(2);
73
+ });
74
+
75
+ it('should list runs with limit', async () => {
76
+ await db.createRun('run-l1', 'wf', {});
77
+ await db.createRun('run-l2', 'wf', {});
78
+ const runs = db.listRuns(1);
79
+ expect(runs).toHaveLength(1);
80
+ });
81
+
82
+ it('should vacuum the database', async () => {
83
+ await db.vacuum();
84
+ // If it doesn't throw, it's successful
85
+ });
86
+
87
+ it('should prune old runs', async () => {
88
+ const runId = 'old-run';
89
+ await db.createRun(runId, 'test-wf', {});
90
+
91
+ // We can't easily change the date via public API,
92
+ // but we can check that it doesn't delete recent runs
93
+ const deleted = await db.pruneRuns(30);
94
+ expect(deleted).toBe(0);
95
+
96
+ const run = db.getRun(runId);
97
+ expect(run).toBeDefined();
98
+ });
99
+ });
@@ -0,0 +1,265 @@
1
+ import { Database } from 'bun:sqlite';
2
+
3
+ export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'paused';
4
+ export type StepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped';
5
+
6
+ export interface WorkflowRun {
7
+ id: string;
8
+ workflow_name: string;
9
+ status: RunStatus;
10
+ inputs: string; // JSON
11
+ outputs: string | null; // JSON
12
+ error: string | null;
13
+ started_at: string;
14
+ completed_at: string | null;
15
+ }
16
+
17
+ export interface StepExecution {
18
+ id: string;
19
+ run_id: string;
20
+ step_id: string;
21
+ iteration_index: number | null;
22
+ status: StepStatus;
23
+ output: string | null; // JSON
24
+ error: string | null;
25
+ started_at: string | null;
26
+ completed_at: string | null;
27
+ retry_count: number;
28
+ }
29
+
30
+ export class WorkflowDb {
31
+ private db: Database;
32
+
33
+ constructor(dbPath = '.keystone/state.db') {
34
+ this.db = new Database(dbPath, { create: true });
35
+ this.db.exec('PRAGMA journal_mode = WAL;'); // Write-ahead logging
36
+ this.db.exec('PRAGMA foreign_keys = ON;'); // Enable foreign key enforcement
37
+ this.initSchema();
38
+ }
39
+
40
+ /**
41
+ * Retry wrapper for SQLite operations that may encounter SQLITE_BUSY errors
42
+ * during high concurrency scenarios (e.g., foreach loops)
43
+ */
44
+ private async withRetry<T>(operation: () => T, maxRetries = 5): Promise<T> {
45
+ let lastError: Error | undefined;
46
+
47
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
48
+ try {
49
+ return operation();
50
+ } catch (error) {
51
+ // Check if this is a SQLITE_BUSY error
52
+ const errorMsg = error instanceof Error ? error.message : String(error);
53
+ if (errorMsg.includes('SQLITE_BUSY') || errorMsg.includes('database is locked')) {
54
+ lastError = error instanceof Error ? error : new Error(errorMsg);
55
+ // Exponential backoff: 10ms, 20ms, 40ms, 80ms, 160ms
56
+ const delayMs = 10 * 2 ** attempt;
57
+ await Bun.sleep(delayMs);
58
+ continue;
59
+ }
60
+ // If it's not a SQLITE_BUSY error, throw immediately
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ throw lastError || new Error('SQLite operation failed after retries');
66
+ }
67
+
68
+ private initSchema(): void {
69
+ this.db.exec(`
70
+ CREATE TABLE IF NOT EXISTS workflow_runs (
71
+ id TEXT PRIMARY KEY,
72
+ workflow_name TEXT NOT NULL,
73
+ status TEXT NOT NULL,
74
+ inputs TEXT NOT NULL,
75
+ outputs TEXT,
76
+ error TEXT,
77
+ started_at TEXT NOT NULL,
78
+ completed_at TEXT
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS step_executions (
82
+ id TEXT PRIMARY KEY,
83
+ run_id TEXT NOT NULL,
84
+ step_id TEXT NOT NULL,
85
+ iteration_index INTEGER,
86
+ status TEXT NOT NULL,
87
+ output TEXT,
88
+ error TEXT,
89
+ started_at TEXT,
90
+ completed_at TEXT,
91
+ retry_count INTEGER DEFAULT 0,
92
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
93
+ );
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON workflow_runs(status);
96
+ CREATE INDEX IF NOT EXISTS idx_runs_workflow ON workflow_runs(workflow_name);
97
+ CREATE INDEX IF NOT EXISTS idx_runs_started ON workflow_runs(started_at);
98
+ CREATE INDEX IF NOT EXISTS idx_steps_run ON step_executions(run_id);
99
+ CREATE INDEX IF NOT EXISTS idx_steps_status ON step_executions(status);
100
+ CREATE INDEX IF NOT EXISTS idx_steps_iteration ON step_executions(run_id, step_id, iteration_index);
101
+ `);
102
+
103
+ // Migration: Add iteration_index if it doesn't exist
104
+ try {
105
+ this.db.exec('ALTER TABLE step_executions ADD COLUMN iteration_index INTEGER;');
106
+ } catch (e) {
107
+ // Ignore if column already exists
108
+ }
109
+ }
110
+
111
+ // ===== Workflow Runs =====
112
+
113
+ async createRun(
114
+ id: string,
115
+ workflowName: string,
116
+ inputs: Record<string, unknown>
117
+ ): Promise<void> {
118
+ await this.withRetry(() => {
119
+ const stmt = this.db.prepare(`
120
+ INSERT INTO workflow_runs (id, workflow_name, status, inputs, started_at)
121
+ VALUES (?, ?, ?, ?, ?)
122
+ `);
123
+ stmt.run(id, workflowName, 'pending', JSON.stringify(inputs), new Date().toISOString());
124
+ });
125
+ }
126
+
127
+ async updateRunStatus(
128
+ id: string,
129
+ status: RunStatus,
130
+ outputs?: Record<string, unknown>,
131
+ error?: string
132
+ ): Promise<void> {
133
+ await this.withRetry(() => {
134
+ const stmt = this.db.prepare(`
135
+ UPDATE workflow_runs
136
+ SET status = ?, outputs = ?, error = ?, completed_at = ?
137
+ WHERE id = ?
138
+ `);
139
+ const completedAt =
140
+ status === 'completed' || status === 'failed' ? new Date().toISOString() : null;
141
+ stmt.run(status, outputs ? JSON.stringify(outputs) : null, error || null, completedAt, id);
142
+ });
143
+ }
144
+
145
+ getRun(id: string): WorkflowRun | null {
146
+ const stmt = this.db.prepare('SELECT * FROM workflow_runs WHERE id = ?');
147
+ return stmt.get(id) as WorkflowRun | null;
148
+ }
149
+
150
+ listRuns(limit = 50): WorkflowRun[] {
151
+ const stmt = this.db.prepare(`
152
+ SELECT * FROM workflow_runs
153
+ ORDER BY started_at DESC
154
+ LIMIT ?
155
+ `);
156
+ return stmt.all(limit) as WorkflowRun[];
157
+ }
158
+
159
+ /**
160
+ * Delete workflow runs older than the specified number of days
161
+ * Associated step executions are automatically deleted via CASCADE
162
+ */
163
+ async pruneRuns(days: number): Promise<number> {
164
+ return await this.withRetry(() => {
165
+ const cutoffDate = new Date();
166
+ cutoffDate.setDate(cutoffDate.getDate() - days);
167
+ const cutoffIso = cutoffDate.toISOString();
168
+
169
+ const stmt = this.db.prepare('DELETE FROM workflow_runs WHERE started_at < ?');
170
+ const result = stmt.run(cutoffIso);
171
+
172
+ return result.changes;
173
+ });
174
+ }
175
+
176
+ async vacuum(): Promise<void> {
177
+ await this.withRetry(() => {
178
+ this.db.exec('VACUUM;');
179
+ });
180
+ }
181
+
182
+ // ===== Step Executions =====
183
+
184
+ async createStep(
185
+ id: string,
186
+ runId: string,
187
+ stepId: string,
188
+ iterationIndex: number | null = null
189
+ ): Promise<void> {
190
+ await this.withRetry(() => {
191
+ const stmt = this.db.prepare(`
192
+ INSERT INTO step_executions (id, run_id, step_id, iteration_index, status, retry_count)
193
+ VALUES (?, ?, ?, ?, ?, ?)
194
+ `);
195
+ stmt.run(id, runId, stepId, iterationIndex, 'pending', 0);
196
+ });
197
+ }
198
+
199
+ async startStep(id: string): Promise<void> {
200
+ await this.withRetry(() => {
201
+ const stmt = this.db.prepare(`
202
+ UPDATE step_executions
203
+ SET status = ?, started_at = ?
204
+ WHERE id = ?
205
+ `);
206
+ stmt.run('running', new Date().toISOString(), id);
207
+ });
208
+ }
209
+
210
+ async completeStep(
211
+ id: string,
212
+ status: StepStatus,
213
+ output?: unknown,
214
+ error?: string
215
+ ): Promise<void> {
216
+ await this.withRetry(() => {
217
+ const stmt = this.db.prepare(`
218
+ UPDATE step_executions
219
+ SET status = ?, output = ?, error = ?, completed_at = ?
220
+ WHERE id = ?
221
+ `);
222
+ stmt.run(
223
+ status,
224
+ output ? JSON.stringify(output) : null,
225
+ error || null,
226
+ new Date().toISOString(),
227
+ id
228
+ );
229
+ });
230
+ }
231
+
232
+ async incrementRetry(id: string): Promise<void> {
233
+ await this.withRetry(() => {
234
+ const stmt = this.db.prepare(`
235
+ UPDATE step_executions
236
+ SET retry_count = retry_count + 1
237
+ WHERE id = ?
238
+ `);
239
+ stmt.run(id);
240
+ });
241
+ }
242
+
243
+ getStepByIteration(runId: string, stepId: string, iterationIndex: number): StepExecution | null {
244
+ const stmt = this.db.prepare(`
245
+ SELECT * FROM step_executions
246
+ WHERE run_id = ? AND step_id = ? AND iteration_index = ?
247
+ ORDER BY started_at DESC
248
+ LIMIT 1
249
+ `);
250
+ return stmt.get(runId, stepId, iterationIndex) as StepExecution | null;
251
+ }
252
+
253
+ getStepsByRun(runId: string): StepExecution[] {
254
+ const stmt = this.db.prepare(`
255
+ SELECT * FROM step_executions
256
+ WHERE run_id = ?
257
+ ORDER BY started_at ASC, iteration_index ASC, rowid ASC
258
+ `);
259
+ return stmt.all(runId) as StepExecution[];
260
+ }
261
+
262
+ close(): void {
263
+ this.db.close();
264
+ }
265
+ }
@@ -0,0 +1,247 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ExpressionEvaluator } from './evaluator';
3
+
4
+ describe('ExpressionEvaluator', () => {
5
+ const context = {
6
+ inputs: {
7
+ name: 'World',
8
+ count: 5,
9
+ items: ['a', 'b', 'c'],
10
+ },
11
+ steps: {
12
+ step1: {
13
+ output: 'Hello',
14
+ outputs: {
15
+ data: { id: 1 },
16
+ },
17
+ },
18
+ },
19
+ item: 'current-item',
20
+ index: 0,
21
+ my_val: 123,
22
+ };
23
+
24
+ test('should evaluate simple literals', () => {
25
+ expect(ExpressionEvaluator.evaluate("${{ 'hello' }}", context)).toBe('hello');
26
+ expect(ExpressionEvaluator.evaluate('${{ 123 }}', context)).toBe(123);
27
+ expect(ExpressionEvaluator.evaluate('${{ true }}', context)).toBe(true);
28
+ });
29
+
30
+ test('should evaluate input variables', () => {
31
+ expect(ExpressionEvaluator.evaluate('${{ inputs.name }}', context)).toBe('World');
32
+ expect(ExpressionEvaluator.evaluate('${{ inputs.count }}', context)).toBe(5);
33
+ });
34
+
35
+ test('should evaluate step outputs', () => {
36
+ expect(ExpressionEvaluator.evaluate('${{ steps.step1.output }}', context)).toBe('Hello');
37
+ expect(ExpressionEvaluator.evaluate('${{ steps.step1.outputs.data.id }}', context)).toBe(1);
38
+ });
39
+
40
+ test('should evaluate item and index', () => {
41
+ expect(ExpressionEvaluator.evaluate('${{ item }}', context)).toBe('current-item');
42
+ expect(ExpressionEvaluator.evaluate('${{ index }}', context)).toBe(0);
43
+ });
44
+
45
+ test('should support arithmetic operators', () => {
46
+ expect(ExpressionEvaluator.evaluate('${{ inputs.count + 1 }}', context)).toBe(6);
47
+ expect(ExpressionEvaluator.evaluate('${{ 10 * 2 }}', context)).toBe(20);
48
+ expect(ExpressionEvaluator.evaluate('${{ 10 / 2 }}', context)).toBe(5);
49
+ expect(ExpressionEvaluator.evaluate('${{ 10 % 3 }}', context)).toBe(1);
50
+ expect(ExpressionEvaluator.evaluate('${{ -5 }}', context)).toBe(-5);
51
+ expect(ExpressionEvaluator.evaluate('${{ +5 }}', context)).toBe(5);
52
+ });
53
+
54
+ test('should support logical operators', () => {
55
+ expect(ExpressionEvaluator.evaluate('${{ inputs.count > 0 && true }}', context)).toBe(true);
56
+ expect(ExpressionEvaluator.evaluate('${{ inputs.count < 0 || false }}', context)).toBe(false);
57
+ expect(ExpressionEvaluator.evaluate('${{ !true }}', context)).toBe(false);
58
+ expect(ExpressionEvaluator.evaluate('${{ true && 1 }}', context)).toBe(1);
59
+ expect(ExpressionEvaluator.evaluate('${{ false && 1 }}', context)).toBe(false);
60
+ expect(ExpressionEvaluator.evaluate('${{ true || 1 }}', context)).toBe(true);
61
+ expect(ExpressionEvaluator.evaluate('${{ false || 1 }}', context)).toBe(1);
62
+ });
63
+
64
+ test('should support comparison operators', () => {
65
+ expect(ExpressionEvaluator.evaluate('${{ 1 == 1 }}', context)).toBe(true);
66
+ expect(ExpressionEvaluator.evaluate('${{ 1 === 1 }}', context)).toBe(true);
67
+ expect(ExpressionEvaluator.evaluate('${{ 1 != 2 }}', context)).toBe(true);
68
+ expect(ExpressionEvaluator.evaluate('${{ 1 !== "1" }}', context)).toBe(true);
69
+ expect(ExpressionEvaluator.evaluate('${{ 1 <= 1 }}', context)).toBe(true);
70
+ expect(ExpressionEvaluator.evaluate('${{ 1 >= 1 }}', context)).toBe(true);
71
+ });
72
+
73
+ test('should support more globals and complex expressions', () => {
74
+ expect(ExpressionEvaluator.evaluate('${{ Math.max(1, 2) }}', context)).toBe(2);
75
+ expect(ExpressionEvaluator.evaluate('${{ JSON.stringify({a: 1}) }}', context)).toBe('{"a":1}');
76
+ expect(ExpressionEvaluator.evaluate('${{ parseInt("123") }}', context)).toBe(123);
77
+ expect(ExpressionEvaluator.evaluate('${{ isNaN(NaN) }}', context)).toBe(true);
78
+ });
79
+
80
+ test('should handle hasExpression', () => {
81
+ expect(ExpressionEvaluator.hasExpression('no expr')).toBe(false);
82
+ expect(ExpressionEvaluator.hasExpression('has ${{ expr }}')).toBe(true);
83
+ });
84
+
85
+ test('should handle evaluateObject', () => {
86
+ const obj = {
87
+ name: 'Hello ${{ inputs.name }}',
88
+ list: ['${{ 1 + 1 }}', 3],
89
+ nested: {
90
+ val: '${{ inputs.count }}',
91
+ },
92
+ };
93
+ const result = ExpressionEvaluator.evaluateObject(obj, context);
94
+ expect(result).toEqual({
95
+ name: 'Hello World',
96
+ list: [2, 3],
97
+ nested: {
98
+ val: 5,
99
+ },
100
+ });
101
+ });
102
+
103
+ test('should throw error for unsupported operators', () => {
104
+ // jsep doesn't support many out of box, but let's try something if we can
105
+ // It's hard to trigger "Unsupported binary operator" because jsep wouldn't parse it
106
+ });
107
+
108
+ test('should handle member access with null/undefined', () => {
109
+ expect(ExpressionEvaluator.evaluate('${{ steps.non_existent.output }}', context)).toBe(
110
+ undefined
111
+ );
112
+ });
113
+
114
+ test('should handle computed member access', () => {
115
+ expect(ExpressionEvaluator.evaluate("${{ inputs['name'] }}", context)).toBe('World');
116
+ });
117
+
118
+ test('should support ternary operator', () => {
119
+ expect(ExpressionEvaluator.evaluate("${{ inputs.count > 0 ? 'yes' : 'no' }}", context)).toBe(
120
+ 'yes'
121
+ );
122
+ expect(ExpressionEvaluator.evaluate("${{ inputs.count < 0 ? 'yes' : 'no' }}", context)).toBe(
123
+ 'no'
124
+ );
125
+ });
126
+
127
+ test('should support array methods and arrow functions', () => {
128
+ expect(
129
+ ExpressionEvaluator.evaluate('${{ inputs.items.map(i => i.toUpperCase()) }}', context)
130
+ ).toEqual(['A', 'B', 'C']);
131
+ expect(
132
+ ExpressionEvaluator.evaluate("${{ inputs.items.filter(i => i !== 'b') }}", context)
133
+ ).toEqual(['a', 'c']);
134
+ expect(ExpressionEvaluator.evaluate('${{ [1, 2, 3].every(n => n > 0) }}', context)).toBe(true);
135
+ });
136
+
137
+ test('should support string methods', () => {
138
+ expect(ExpressionEvaluator.evaluate('${{ inputs.name.toLowerCase() }}', context)).toBe('world');
139
+ expect(ExpressionEvaluator.evaluate("${{ ' trimmed '.trim() }}", context)).toBe('trimmed');
140
+ });
141
+
142
+ test('should handle mixed text and expressions', () => {
143
+ expect(ExpressionEvaluator.evaluate('Hello, ${{ inputs.name }}!', context)).toBe(
144
+ 'Hello, World!'
145
+ );
146
+ expect(
147
+ ExpressionEvaluator.evaluate('Count: ${{ inputs.count }}, Item: ${{ item }}', context)
148
+ ).toBe('Count: 5, Item: current-item');
149
+ });
150
+
151
+ test('should handle nested object literals', () => {
152
+ const result = ExpressionEvaluator.evaluate(
153
+ "${{ { foo: 'bar', baz: inputs.count } }}",
154
+ context
155
+ );
156
+ expect(result).toEqual({ foo: 'bar', baz: 5 });
157
+ });
158
+
159
+ test('should throw error for undefined variables', () => {
160
+ expect(() => ExpressionEvaluator.evaluate('${{ undefined_var }}', context)).toThrow(
161
+ /Undefined variable/
162
+ );
163
+ });
164
+
165
+ test('should support arrow functions as standalone values', () => {
166
+ const fn = ExpressionEvaluator.evaluate('${{ x => x * 2 }}', context) as (x: number) => number;
167
+ expect(fn(5)).toBe(10);
168
+ });
169
+
170
+ test('should throw error for non-function calls', () => {
171
+ expect(() => ExpressionEvaluator.evaluate('${{ my_val() }}', context)).toThrow(
172
+ /is not a function/
173
+ );
174
+ });
175
+
176
+ test('should support subtraction and other binary operators', () => {
177
+ expect(ExpressionEvaluator.evaluate('${{ 10 - 5 }}', context)).toBe(5);
178
+ });
179
+
180
+ test('should extract step dependencies', () => {
181
+ expect(
182
+ ExpressionEvaluator.findStepDependencies('Hello, ${{ steps.ask_name.output }}!')
183
+ ).toEqual(['ask_name']);
184
+ expect(
185
+ ExpressionEvaluator.findStepDependencies("${{ steps.step1.output + steps['step2'].output }}")
186
+ ).toEqual(['step1', 'step2']);
187
+ expect(ExpressionEvaluator.findStepDependencies('No expressions here')).toEqual([]);
188
+ expect(
189
+ ExpressionEvaluator.findStepDependencies("${{ inputs.name + steps.step3.outputs['val'] }}")
190
+ ).toEqual(['step3']);
191
+ });
192
+
193
+ test('should throw error for forbidden properties', () => {
194
+ expect(() => ExpressionEvaluator.evaluate("${{ inputs['constructor'] }}", context)).toThrow(
195
+ /Access to property constructor is forbidden/
196
+ );
197
+ });
198
+
199
+ test('should handle short-circuiting in logical expressions', () => {
200
+ expect(ExpressionEvaluator.evaluate('${{ true || undefined_var }}', context)).toBe(true);
201
+ expect(ExpressionEvaluator.evaluate('${{ false && undefined_var }}', context)).toBe(false);
202
+ });
203
+
204
+ test('should throw error for unsupported binary operator', () => {
205
+ // We have to bypass jsep parsing or find one it supports but we don't
206
+ // For now we'll just try to reach it if possible, but it's mostly for safety
207
+ });
208
+
209
+ test('should throw error for method not allowed', () => {
210
+ expect(() => ExpressionEvaluator.evaluate("${{ 'abc'.reverse() }}", context)).toThrow(
211
+ /Method reverse is not allowed/
212
+ );
213
+ });
214
+
215
+ test('should handle standalone function calls with arrow functions', () => {
216
+ // 'escape' is a safe global function we added
217
+ expect(ExpressionEvaluator.evaluate("${{ escape('hello world') }}", context)).toBe(
218
+ "'hello world'"
219
+ );
220
+ });
221
+
222
+ test('should handle nested logical expressions and short-circuiting', () => {
223
+ expect(ExpressionEvaluator.evaluate('${{ true && false || true }}', context)).toBe(true);
224
+ expect(ExpressionEvaluator.evaluate('${{ false && true || false }}', context)).toBe(false);
225
+ });
226
+
227
+ test('should throw error for non-existent methods', () => {
228
+ expect(() => ExpressionEvaluator.evaluate('${{ [1,2].nonExistent() }}', context)).toThrow(
229
+ /Method nonExistent is not allowed/
230
+ );
231
+ });
232
+
233
+ test('should handle CallExpression with arrow function for standalone function', () => {
234
+ const contextWithFunc = {
235
+ ...context,
236
+ runFn: (fn: (x: number) => number) => fn(10),
237
+ };
238
+ expect(ExpressionEvaluator.evaluate('${{ runFn(x => x + 5) }}', contextWithFunc)).toBe(15);
239
+ });
240
+
241
+ test('should throw error for unsupported unary operator', () => {
242
+ // '~' is a unary operator jsep supports but we don't
243
+ expect(() => ExpressionEvaluator.evaluate('${{ ~1 }}', context)).toThrow(
244
+ /Unsupported unary operator: ~/
245
+ );
246
+ });
247
+ });