@positronic/core 0.0.1 → 0.0.3
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/CLAUDE.md +141 -0
- package/dist/src/adapters/types.js +1 -16
- package/dist/src/clients/types.js +4 -1
- package/dist/src/dsl/brain-runner.js +487 -0
- package/dist/src/dsl/brain-runner.test.js +733 -0
- package/dist/src/dsl/brain.js +1128 -0
- package/dist/src/dsl/brain.test.js +4225 -0
- package/dist/src/dsl/constants.js +6 -6
- package/dist/src/dsl/json-patch.js +37 -9
- package/dist/src/index.js +11 -10
- package/dist/src/resources/resources.js +371 -0
- package/dist/src/test-utils.js +474 -0
- package/dist/src/testing.js +3 -0
- package/dist/types/adapters/types.d.ts +3 -8
- package/dist/types/adapters/types.d.ts.map +1 -1
- package/dist/types/clients/types.d.ts +46 -6
- package/dist/types/clients/types.d.ts.map +1 -1
- package/dist/types/dsl/brain-runner.d.ts +24 -0
- package/dist/types/dsl/brain-runner.d.ts.map +1 -0
- package/dist/types/dsl/brain.d.ts +136 -0
- package/dist/types/dsl/brain.d.ts.map +1 -0
- package/dist/types/dsl/constants.d.ts +5 -5
- package/dist/types/dsl/constants.d.ts.map +1 -1
- package/dist/types/dsl/json-patch.d.ts +2 -1
- package/dist/types/dsl/json-patch.d.ts.map +1 -1
- package/dist/types/index.d.ts +13 -11
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/resources/resource-loader.d.ts +6 -0
- package/dist/types/resources/resource-loader.d.ts.map +1 -0
- package/dist/types/resources/resources.d.ts +23 -0
- package/dist/types/resources/resources.d.ts.map +1 -0
- package/dist/types/test-utils.d.ts +94 -0
- package/dist/types/test-utils.d.ts.map +1 -0
- package/dist/types/testing.d.ts +2 -0
- package/dist/types/testing.d.ts.map +1 -0
- package/docs/core-testing-guide.md +289 -0
- package/package.json +26 -7
- package/src/adapters/types.ts +3 -22
- package/src/clients/types.ts +50 -10
- package/src/dsl/brain-runner.test.ts +384 -0
- package/src/dsl/brain-runner.ts +111 -0
- package/src/dsl/brain.test.ts +1981 -0
- package/src/dsl/brain.ts +740 -0
- package/src/dsl/constants.ts +6 -6
- package/src/dsl/json-patch.ts +24 -9
- package/src/dsl/types.ts +1 -1
- package/src/index.ts +30 -16
- package/src/resources/resource-loader.ts +8 -0
- package/src/resources/resources.ts +267 -0
- package/src/test-utils.ts +254 -0
- package/test/resources.test.ts +248 -0
- package/tsconfig.json +2 -2
- package/.swcrc +0 -31
- package/dist/src/dsl/extensions.js +0 -19
- package/dist/src/dsl/workflow-runner.js +0 -93
- package/dist/src/dsl/workflow.js +0 -308
- package/dist/src/file-stores/local-file-store.js +0 -12
- package/dist/src/utils/temp-files.js +0 -27
- package/dist/types/dsl/extensions.d.ts +0 -18
- package/dist/types/dsl/extensions.d.ts.map +0 -1
- package/dist/types/dsl/workflow-runner.d.ts +0 -28
- package/dist/types/dsl/workflow-runner.d.ts.map +0 -1
- package/dist/types/dsl/workflow.d.ts +0 -118
- package/dist/types/dsl/workflow.d.ts.map +0 -1
- package/dist/types/file-stores/local-file-store.d.ts +0 -7
- package/dist/types/file-stores/local-file-store.d.ts.map +0 -1
- package/dist/types/file-stores/types.d.ts +0 -4
- package/dist/types/file-stores/types.d.ts.map +0 -1
- package/dist/types/utils/temp-files.d.ts +0 -12
- package/dist/types/utils/temp-files.d.ts.map +0 -1
- package/src/dsl/extensions.ts +0 -58
- package/src/dsl/workflow-runner.test.ts +0 -203
- package/src/dsl/workflow-runner.ts +0 -146
- package/src/dsl/workflow.test.ts +0 -1435
- package/src/dsl/workflow.ts +0 -554
- package/src/file-stores/local-file-store.ts +0 -11
- package/src/file-stores/types.ts +0 -3
- package/src/utils/temp-files.ts +0 -46
- /package/dist/src/{file-stores/types.js → resources/resource-loader.js} +0 -0
package/src/dsl/constants.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export const
|
|
2
|
-
START: '
|
|
3
|
-
RESTART: '
|
|
1
|
+
export const BRAIN_EVENTS = {
|
|
2
|
+
START: 'brain:start',
|
|
3
|
+
RESTART: 'brain:restart',
|
|
4
4
|
STEP_START: 'step:start',
|
|
5
5
|
STEP_COMPLETE: 'step:complete',
|
|
6
6
|
STEP_STATUS: 'step:status',
|
|
7
|
-
ERROR: '
|
|
8
|
-
COMPLETE: '
|
|
7
|
+
ERROR: 'brain:error',
|
|
8
|
+
COMPLETE: 'brain:complete',
|
|
9
9
|
} as const;
|
|
10
10
|
|
|
11
11
|
export const STATUS = {
|
|
@@ -13,4 +13,4 @@ export const STATUS = {
|
|
|
13
13
|
RUNNING: 'running',
|
|
14
14
|
COMPLETE: 'complete',
|
|
15
15
|
ERROR: 'error',
|
|
16
|
-
} as const;
|
|
16
|
+
} as const;
|
package/src/dsl/json-patch.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import pkg from 'fast-json-patch';
|
|
2
2
|
const { compare, applyPatch } = pkg;
|
|
3
|
-
import { JsonPatch, State } from './types';
|
|
3
|
+
import { JsonPatch, State } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type { JsonPatch };
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Creates a JSON Patch that describes the changes needed to transform prevState into nextState.
|
|
7
9
|
*/
|
|
8
10
|
export function createPatch(prevState: State, nextState: State): JsonPatch {
|
|
9
11
|
// Filter out non-standard operations and ensure type safety
|
|
10
|
-
return compare(prevState, nextState).filter(op =>
|
|
12
|
+
return compare(prevState, nextState).filter((op) =>
|
|
11
13
|
['add', 'remove', 'replace', 'move', 'copy', 'test'].includes(op.op)
|
|
12
14
|
) as JsonPatch;
|
|
13
15
|
}
|
|
@@ -16,12 +18,25 @@ export function createPatch(prevState: State, nextState: State): JsonPatch {
|
|
|
16
18
|
* Applies one or more JSON Patches to a state object and returns the resulting state.
|
|
17
19
|
* If multiple patches are provided, they are applied in sequence.
|
|
18
20
|
*/
|
|
19
|
-
export function applyPatches(
|
|
20
|
-
|
|
21
|
+
export function applyPatches(
|
|
22
|
+
state: State,
|
|
23
|
+
patches: JsonPatch | JsonPatch[]
|
|
24
|
+
): State {
|
|
25
|
+
const patchArray = Array.isArray(patches[0])
|
|
26
|
+
? (patches as JsonPatch[])
|
|
27
|
+
: [patches as JsonPatch];
|
|
21
28
|
|
|
22
29
|
// Apply patches in sequence, creating a new state object each time
|
|
23
|
-
return patchArray.reduce(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
return patchArray.reduce(
|
|
31
|
+
(currentState, patch) => {
|
|
32
|
+
const { newDocument } = applyPatch(
|
|
33
|
+
currentState,
|
|
34
|
+
patch as any[],
|
|
35
|
+
true,
|
|
36
|
+
false
|
|
37
|
+
);
|
|
38
|
+
return newDocument;
|
|
39
|
+
},
|
|
40
|
+
{ ...state }
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/dsl/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export { Adapter } from "./adapters/types";
|
|
1
|
+
export { Brain, brain } from './dsl/brain.js';
|
|
2
|
+
export { BrainRunner } from './dsl/brain-runner.js';
|
|
3
|
+
export { STATUS, BRAIN_EVENTS } from './dsl/constants.js';
|
|
4
|
+
export type { Adapter } from './adapters/types.js';
|
|
6
5
|
export type {
|
|
7
|
-
|
|
6
|
+
BrainEvent,
|
|
8
7
|
SerializedStep,
|
|
9
8
|
InitialRunParams,
|
|
10
9
|
RerunParams,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
BrainStartEvent,
|
|
11
|
+
BrainCompleteEvent,
|
|
12
|
+
BrainErrorEvent,
|
|
14
13
|
StepStatusEvent,
|
|
15
14
|
StepStartedEvent,
|
|
16
|
-
StepCompletedEvent
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export type {
|
|
21
|
-
export {
|
|
22
|
-
export {
|
|
15
|
+
StepCompletedEvent,
|
|
16
|
+
BrainStructure,
|
|
17
|
+
BrainFactory,
|
|
18
|
+
} from './dsl/brain.js';
|
|
19
|
+
export type { ObjectGenerator, Message } from './clients/types.js';
|
|
20
|
+
export type { State } from './dsl/types.js';
|
|
21
|
+
export { createPatch, applyPatches } from './dsl/json-patch.js';
|
|
22
|
+
|
|
23
|
+
// Only needed for development to ensure that zod version numbers are the same, it's a peer
|
|
24
|
+
// dependency so when not using file://..path/to/package links the version numbers
|
|
25
|
+
// will match just fine if the user has the same version of zod installed.
|
|
26
|
+
// NOTE: Not 100% sure this is still needed - worth re-evaluating if we can remove this.
|
|
27
|
+
export { z } from 'zod';
|
|
28
|
+
|
|
29
|
+
export type { ResourceLoader } from './resources/resource-loader.js';
|
|
30
|
+
export { createResources, type Resources } from './resources/resources.js';
|
|
31
|
+
export type {
|
|
32
|
+
Manifest as ResourceManifest,
|
|
33
|
+
Entry as ResourceEntry,
|
|
34
|
+
ResourceType,
|
|
35
|
+
} from './resources/resources.js';
|
|
36
|
+
export { RESOURCE_TYPES } from './resources/resources.js';
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ResourceLoader } from './resource-loader.js';
|
|
2
|
+
|
|
3
|
+
// Runtime array of valid resource types
|
|
4
|
+
export const RESOURCE_TYPES = ['text', 'binary'] as const;
|
|
5
|
+
export type ResourceType = (typeof RESOURCE_TYPES)[number];
|
|
6
|
+
|
|
7
|
+
export interface Entry {
|
|
8
|
+
type: ResourceType;
|
|
9
|
+
path: string; // File path - used during build process
|
|
10
|
+
key: string; // R2 object key (original filename with path)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Manifest {
|
|
14
|
+
[key: string]: ManifestEntry;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ManifestEntry = Entry | Manifest;
|
|
18
|
+
|
|
19
|
+
interface Resource {
|
|
20
|
+
load: () => Promise<string | Buffer>;
|
|
21
|
+
loadText: () => Promise<string>;
|
|
22
|
+
loadBinary: () => Promise<Buffer>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Resources {
|
|
26
|
+
[key: string]: Resource | Resources;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isResourceEntry(entry: ManifestEntry): entry is Entry {
|
|
30
|
+
return typeof (entry as Entry).type === 'string';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createResources<M extends Manifest>(
|
|
34
|
+
loader: ResourceLoader,
|
|
35
|
+
initialManifest: M
|
|
36
|
+
) {
|
|
37
|
+
// Helper function to find a resource entry by path in the manifest
|
|
38
|
+
function findResourceByPath(manifest: Manifest, path: string): Entry | null {
|
|
39
|
+
const parts = path.split('/');
|
|
40
|
+
|
|
41
|
+
let current: Manifest | Entry = manifest;
|
|
42
|
+
for (let i = 0; i < parts.length; i++) {
|
|
43
|
+
const part = parts[i];
|
|
44
|
+
if (isResourceEntry(current)) {
|
|
45
|
+
// We hit a resource entry before consuming all parts
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currentManifest = current as Manifest;
|
|
50
|
+
const next = currentManifest[part];
|
|
51
|
+
if (!next) {
|
|
52
|
+
// If exact match not found and this is the last part, try without extension
|
|
53
|
+
if (i === parts.length - 1) {
|
|
54
|
+
const partWithoutExt = part.replace(/\.[^/.]+$/, '');
|
|
55
|
+
const currentManifest = current as Manifest; // We know it's a Manifest since we checked isResourceEntry above
|
|
56
|
+
const matches = Object.keys(currentManifest).filter((key) => {
|
|
57
|
+
const keyWithoutExt = key.replace(/\.[^/.]+$/, '');
|
|
58
|
+
return (
|
|
59
|
+
keyWithoutExt === partWithoutExt &&
|
|
60
|
+
isResourceEntry(currentManifest[key])
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (matches.length === 1) {
|
|
65
|
+
return currentManifest[matches[0]] as Entry;
|
|
66
|
+
} else if (matches.length > 1) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Ambiguous resource path '${path}': found ${matches.join(
|
|
69
|
+
', '
|
|
70
|
+
)}. ` + `Please specify the full filename with extension.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (i === parts.length - 1 && isResourceEntry(next)) {
|
|
78
|
+
// Found the resource
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
current = next as Manifest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createProxiedResources(manifestNode: Manifest): Resources {
|
|
89
|
+
// Create methods that will be shared across all instances
|
|
90
|
+
const loadText = async (path: string): Promise<string> => {
|
|
91
|
+
const entry = findResourceByPath(manifestNode, path);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
throw new Error(`Resource not found: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
if (entry.type !== 'text') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Resource "${path}" is of type "${entry.type}", but was accessed with loadText().`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return loader.load(entry.key, 'text');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const loadBinary = async (path: string) => {
|
|
104
|
+
const entry = findResourceByPath(manifestNode, path);
|
|
105
|
+
if (!entry) {
|
|
106
|
+
throw new Error(`Resource not found: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
if (entry.type !== 'binary') {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Resource "${path}" is of type "${entry.type}", but was accessed with loadBinary().`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return loader.load(entry.key, 'binary');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const resultProxy: Resources = new Proxy({} as Resources, {
|
|
117
|
+
get: (target, prop, receiver): any => {
|
|
118
|
+
if (prop === 'loadText') {
|
|
119
|
+
return loadText;
|
|
120
|
+
}
|
|
121
|
+
if (prop === 'loadBinary') {
|
|
122
|
+
return loadBinary;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Handle dynamic resource properties
|
|
126
|
+
if (typeof prop !== 'string') {
|
|
127
|
+
return Reflect.get(target, prop, receiver);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check if the property exists directly (with extension)
|
|
131
|
+
if (prop in manifestNode) {
|
|
132
|
+
const manifestEntry = manifestNode[prop];
|
|
133
|
+
|
|
134
|
+
if (isResourceEntry(manifestEntry)) {
|
|
135
|
+
const { key, type } = manifestEntry;
|
|
136
|
+
const apiObject: Resource = {
|
|
137
|
+
load: () =>
|
|
138
|
+
manifestEntry.type === 'text'
|
|
139
|
+
? loader.load(key, 'text')
|
|
140
|
+
: loader.load(key, 'binary'),
|
|
141
|
+
loadText: () => {
|
|
142
|
+
if (type !== 'text') {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Resource "${prop}" is of type "${type}", but was accessed with loadText().`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return loader.load(key, 'text');
|
|
148
|
+
},
|
|
149
|
+
loadBinary: () => {
|
|
150
|
+
if (type !== 'binary') {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Resource "${prop}" is of type "${type}", but was accessed with loadBinary().`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return loader.load(key, 'binary');
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
return apiObject;
|
|
159
|
+
} else {
|
|
160
|
+
// manifestEntry is a nested Manifest
|
|
161
|
+
const nestedResources = createProxiedResources(
|
|
162
|
+
manifestEntry as Manifest
|
|
163
|
+
);
|
|
164
|
+
return nestedResources;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If not found directly, check for files without extension
|
|
169
|
+
// Find all keys that match when extension is removed
|
|
170
|
+
const matches = Object.keys(manifestNode).filter((key) => {
|
|
171
|
+
const keyWithoutExt = key.replace(/\.[^/.]+$/, '');
|
|
172
|
+
return keyWithoutExt === prop && isResourceEntry(manifestNode[key]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (matches.length === 0) {
|
|
176
|
+
// No matches - might be a nested directory
|
|
177
|
+
if (prop in manifestNode && !isResourceEntry(manifestNode[prop])) {
|
|
178
|
+
return createProxiedResources(manifestNode[prop] as Manifest);
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (matches.length === 1) {
|
|
184
|
+
// Single match - return it
|
|
185
|
+
const manifestEntry = manifestNode[matches[0]] as Entry;
|
|
186
|
+
const { key, type } = manifestEntry;
|
|
187
|
+
const apiObject: Resource = {
|
|
188
|
+
load: () =>
|
|
189
|
+
type === 'text'
|
|
190
|
+
? loader.load(key, 'text')
|
|
191
|
+
: loader.load(key, 'binary'),
|
|
192
|
+
loadText: () => {
|
|
193
|
+
if (type !== 'text') {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Resource "${matches[0]}" is of type "${type}", but was accessed with loadText().`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return loader.load(key, 'text');
|
|
199
|
+
},
|
|
200
|
+
loadBinary: () => {
|
|
201
|
+
if (type !== 'binary') {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Resource "${matches[0]}" is of type "${type}", but was accessed with loadBinary().`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return loader.load(key, 'binary');
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
return apiObject;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Multiple matches - throw helpful error
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Ambiguous resource name '${prop}': found ${matches.join(', ')}. ` +
|
|
215
|
+
`Please use resources.loadText('${matches[0]}') or resources.loadBinary('${matches[1]}') instead.`
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
has: (target, prop): boolean => {
|
|
219
|
+
// Check for special methods
|
|
220
|
+
if (prop === 'loadText' || prop === 'loadBinary') {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
// Then check manifest
|
|
224
|
+
if (typeof prop === 'string') {
|
|
225
|
+
return prop in manifestNode;
|
|
226
|
+
}
|
|
227
|
+
return Reflect.has(target, prop);
|
|
228
|
+
},
|
|
229
|
+
ownKeys: (target): string[] => {
|
|
230
|
+
// Combine special methods with manifest keys
|
|
231
|
+
return ['loadText', 'loadBinary', ...Object.keys(manifestNode)];
|
|
232
|
+
},
|
|
233
|
+
getOwnPropertyDescriptor: (
|
|
234
|
+
target,
|
|
235
|
+
prop
|
|
236
|
+
): PropertyDescriptor | undefined => {
|
|
237
|
+
if (prop === 'loadText' || prop === 'loadBinary') {
|
|
238
|
+
return {
|
|
239
|
+
value: prop === 'loadText' ? loadText : loadBinary,
|
|
240
|
+
writable: false,
|
|
241
|
+
enumerable: true,
|
|
242
|
+
configurable: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof prop === 'string' && prop in manifestNode) {
|
|
247
|
+
const value: Resource | Resources | undefined =
|
|
248
|
+
resultProxy[prop as keyof Resources];
|
|
249
|
+
if (value === undefined) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
value,
|
|
254
|
+
writable: false,
|
|
255
|
+
enumerable: true,
|
|
256
|
+
configurable: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return resultProxy;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return createProxiedResources(initialManifest);
|
|
267
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { ObjectGenerator, Message } from './clients/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { jest } from '@jest/globals';
|
|
4
|
+
import type { Adapter } from './adapters/types.js';
|
|
5
|
+
import type { BrainEvent, Brain } from './dsl/brain.js';
|
|
6
|
+
import type { State } from './dsl/types.js';
|
|
7
|
+
import { BRAIN_EVENTS } from './dsl/constants.js';
|
|
8
|
+
import { applyPatches } from './dsl/json-patch.js';
|
|
9
|
+
import { BrainRunner } from './dsl/brain-runner.js';
|
|
10
|
+
import type { Resources } from './resources/resources.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mock implementation of ObjectGenerator for testing
|
|
14
|
+
*/
|
|
15
|
+
export class MockObjectGenerator implements ObjectGenerator {
|
|
16
|
+
private generateObjectMock: jest.Mock<any>;
|
|
17
|
+
private calls: Array<{
|
|
18
|
+
params: Parameters<ObjectGenerator['generateObject']>[0];
|
|
19
|
+
timestamp: Date;
|
|
20
|
+
}> = [];
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
this.generateObjectMock = jest.fn();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async generateObject<T extends z.AnyZodObject>(
|
|
27
|
+
params: Parameters<ObjectGenerator['generateObject']>[0]
|
|
28
|
+
): Promise<z.infer<T>> {
|
|
29
|
+
this.calls.push({ params, timestamp: new Date() });
|
|
30
|
+
return this.generateObjectMock(params) as Promise<z.infer<T>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mock a response for the next generateObject call
|
|
35
|
+
*/
|
|
36
|
+
mockNextResponse<T>(response: T): void {
|
|
37
|
+
this.generateObjectMock.mockResolvedValueOnce(response as any);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mock multiple responses in sequence
|
|
42
|
+
*/
|
|
43
|
+
mockResponses(...responses: any[]): void {
|
|
44
|
+
responses.forEach((response) => {
|
|
45
|
+
this.generateObjectMock.mockResolvedValueOnce(response as any);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mock an error for the next generateObject call
|
|
51
|
+
*/
|
|
52
|
+
mockNextError(error: Error | string): void {
|
|
53
|
+
const errorObj = typeof error === 'string' ? new Error(error) : error;
|
|
54
|
+
this.generateObjectMock.mockRejectedValueOnce(errorObj as any);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the underlying jest.Mock for advanced mocking scenarios
|
|
59
|
+
*/
|
|
60
|
+
get mock(): jest.Mock<any> {
|
|
61
|
+
return this.generateObjectMock;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear all mocks and call history
|
|
66
|
+
*/
|
|
67
|
+
clear(): void {
|
|
68
|
+
this.generateObjectMock.mockClear();
|
|
69
|
+
this.calls = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reset mock to initial state
|
|
74
|
+
*/
|
|
75
|
+
reset(): void {
|
|
76
|
+
this.generateObjectMock.mockReset();
|
|
77
|
+
this.calls = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get all calls made to generateObject
|
|
82
|
+
*/
|
|
83
|
+
getCalls() {
|
|
84
|
+
return this.calls;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the last call made to generateObject
|
|
89
|
+
*/
|
|
90
|
+
getLastCall() {
|
|
91
|
+
return this.calls[this.calls.length - 1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assert that generateObject was called with specific parameters
|
|
96
|
+
*/
|
|
97
|
+
expectCalledWith(expected: {
|
|
98
|
+
prompt?: string | ((actual: string) => boolean);
|
|
99
|
+
schemaName?: string;
|
|
100
|
+
messages?: Message[];
|
|
101
|
+
system?: string;
|
|
102
|
+
}): void {
|
|
103
|
+
const lastCall = this.getLastCall();
|
|
104
|
+
if (!lastCall) {
|
|
105
|
+
throw new Error('No calls made to generateObject');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (expected.prompt !== undefined) {
|
|
109
|
+
if (typeof expected.prompt === 'function') {
|
|
110
|
+
expect(expected.prompt(lastCall.params.prompt || '')).toBe(true);
|
|
111
|
+
} else {
|
|
112
|
+
expect(lastCall.params.prompt).toBe(expected.prompt);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (expected.schemaName !== undefined) {
|
|
117
|
+
expect(lastCall.params.schemaName).toBe(expected.schemaName);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (expected.messages !== undefined) {
|
|
121
|
+
expect(lastCall.params.messages).toEqual(expected.messages);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (expected.system !== undefined) {
|
|
125
|
+
expect(lastCall.params.system).toBe(expected.system);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Assert that generateObject was called N times
|
|
131
|
+
*/
|
|
132
|
+
expectCallCount(count: number): void {
|
|
133
|
+
expect(this.generateObjectMock).toHaveBeenCalledTimes(count);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates a mock client for testing brains
|
|
139
|
+
*/
|
|
140
|
+
export function createMockClient(): MockObjectGenerator {
|
|
141
|
+
return new MockObjectGenerator();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Test adapter that collects brain execution results
|
|
146
|
+
*/
|
|
147
|
+
class TestAdapter<TState extends State = State> implements Adapter {
|
|
148
|
+
private stepTitles: string[] = [];
|
|
149
|
+
private currentState: TState;
|
|
150
|
+
private completed = false;
|
|
151
|
+
private error: Error | null = null;
|
|
152
|
+
|
|
153
|
+
constructor(private initialState: TState) {
|
|
154
|
+
this.currentState = { ...initialState };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async dispatch(event: BrainEvent): Promise<void> {
|
|
158
|
+
switch (event.type) {
|
|
159
|
+
case BRAIN_EVENTS.STEP_COMPLETE:
|
|
160
|
+
this.stepTitles.push(event.stepTitle);
|
|
161
|
+
if (event.patch && event.patch.length > 0) {
|
|
162
|
+
this.currentState = applyPatches(this.currentState, event.patch) as TState;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case BRAIN_EVENTS.COMPLETE:
|
|
166
|
+
this.completed = true;
|
|
167
|
+
break;
|
|
168
|
+
case BRAIN_EVENTS.ERROR:
|
|
169
|
+
this.error = event.error;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getExecutedSteps(): string[] {
|
|
175
|
+
return [...this.stepTitles];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getFinalState(): TState {
|
|
179
|
+
return { ...this.currentState };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
isCompleted(): boolean {
|
|
183
|
+
return this.completed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getError(): Error | null {
|
|
187
|
+
return this.error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Test runner options
|
|
193
|
+
*/
|
|
194
|
+
export interface TestRunnerOptions<TState extends State = State> {
|
|
195
|
+
client?: ObjectGenerator;
|
|
196
|
+
resources?: Resources;
|
|
197
|
+
initialState?: TState;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Result of running a brain test
|
|
202
|
+
*/
|
|
203
|
+
export interface TestRunResult<TState extends State = State> {
|
|
204
|
+
finalState: TState;
|
|
205
|
+
steps: string[];
|
|
206
|
+
error: Error | null;
|
|
207
|
+
completed: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Runs a brain with test utilities and returns collected data
|
|
212
|
+
*/
|
|
213
|
+
export async function runBrainTest<
|
|
214
|
+
TOptions extends object = {},
|
|
215
|
+
TState extends State = {}
|
|
216
|
+
>(
|
|
217
|
+
brain: Brain<TOptions, TState, any>,
|
|
218
|
+
options: TestRunnerOptions<TState> & { brainOptions?: TOptions } = {}
|
|
219
|
+
): Promise<TestRunResult<TState>> {
|
|
220
|
+
const {
|
|
221
|
+
client = createMockClient(),
|
|
222
|
+
resources,
|
|
223
|
+
initialState = {} as TState,
|
|
224
|
+
brainOptions,
|
|
225
|
+
} = options;
|
|
226
|
+
|
|
227
|
+
// Create test adapter
|
|
228
|
+
const testAdapter = new TestAdapter<TState>(initialState);
|
|
229
|
+
|
|
230
|
+
// Create brain runner with test adapter
|
|
231
|
+
const runner = new BrainRunner({
|
|
232
|
+
adapters: [testAdapter],
|
|
233
|
+
client,
|
|
234
|
+
resources,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Run the brain
|
|
239
|
+
await runner.run(brain, {
|
|
240
|
+
initialState,
|
|
241
|
+
options: brainOptions,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// Brain might throw after emitting ERROR event
|
|
245
|
+
// This is expected behavior, so we don't re-throw
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
finalState: testAdapter.getFinalState(),
|
|
250
|
+
steps: testAdapter.getExecutedSteps(),
|
|
251
|
+
error: testAdapter.getError(),
|
|
252
|
+
completed: testAdapter.isCompleted(),
|
|
253
|
+
};
|
|
254
|
+
}
|