@positronic/cloudflare 0.0.2
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/package.json +45 -0
- package/src/api.ts +579 -0
- package/src/brain-runner-do.ts +309 -0
- package/src/dev-server.ts +776 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +69 -0
- package/src/monitor-do.ts +268 -0
- package/src/node-index.ts +15 -0
- package/src/r2-loader.ts +27 -0
- package/src/schedule-do.ts +377 -0
- package/src/sqlite-adapter.ts +50 -0
- package/test-project/package-lock.json +3010 -0
- package/test-project/package.json +21 -0
- package/test-project/src/index.ts +70 -0
- package/test-project/src/runner.ts +24 -0
- package/test-project/tests/api.test.ts +1005 -0
- package/test-project/tests/r2loader.test.ts +73 -0
- package/test-project/tests/resources-api.test.ts +671 -0
- package/test-project/tests/spec.test.ts +135 -0
- package/test-project/tests/tsconfig.json +7 -0
- package/test-project/tsconfig.json +20 -0
- package/test-project/vitest.config.ts +12 -0
- package/test-project/wrangler.jsonc +53 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
import {
|
|
2
|
+
env,
|
|
3
|
+
createExecutionContext,
|
|
4
|
+
waitOnExecutionContext,
|
|
5
|
+
} from 'cloudflare:test';
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
8
|
+
import worker from '../src/index';
|
|
9
|
+
import { BRAIN_EVENTS, STATUS } from '@positronic/core';
|
|
10
|
+
import type {
|
|
11
|
+
BrainEvent,
|
|
12
|
+
BrainStartEvent,
|
|
13
|
+
BrainCompleteEvent,
|
|
14
|
+
StepStatusEvent,
|
|
15
|
+
StepCompletedEvent,
|
|
16
|
+
StepStartedEvent,
|
|
17
|
+
} from '@positronic/core';
|
|
18
|
+
import type { BrainRunnerDO } from '../../src/brain-runner-do.js';
|
|
19
|
+
import type { MonitorDO } from '../../src/monitor-do.js';
|
|
20
|
+
import type { ScheduleDO } from '../../src/schedule-do.js';
|
|
21
|
+
|
|
22
|
+
interface TestEnv {
|
|
23
|
+
BRAIN_RUNNER_DO: DurableObjectNamespace<BrainRunnerDO>;
|
|
24
|
+
MONITOR_DO: DurableObjectNamespace<MonitorDO>;
|
|
25
|
+
SCHEDULE_DO: DurableObjectNamespace<ScheduleDO>;
|
|
26
|
+
DB: D1Database;
|
|
27
|
+
RESOURCES_BUCKET: R2Bucket;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Hono API Tests', () => {
|
|
31
|
+
// Helper to parse SSE data field
|
|
32
|
+
function parseSseEvent(text: string): any | null {
|
|
33
|
+
const lines = text.trim().split('\n');
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (line.startsWith('data: ')) {
|
|
36
|
+
try {
|
|
37
|
+
const jsonData = line.substring(6); // Length of "data: "
|
|
38
|
+
const parsed = JSON.parse(jsonData);
|
|
39
|
+
return parsed;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error(
|
|
42
|
+
'[TEST_SSE_PARSE] Failed to parse SSE data:',
|
|
43
|
+
line.substring(6),
|
|
44
|
+
e
|
|
45
|
+
);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Helper function to read the entire SSE stream and collect events
|
|
54
|
+
async function readSseStream(
|
|
55
|
+
stream: ReadableStream<Uint8Array>
|
|
56
|
+
): Promise<BrainEvent[]> {
|
|
57
|
+
const reader = stream.getReader();
|
|
58
|
+
const decoder = new TextDecoder();
|
|
59
|
+
let buffer = '';
|
|
60
|
+
const events: BrainEvent[] = [];
|
|
61
|
+
|
|
62
|
+
while (true) {
|
|
63
|
+
const { value, done } = await reader.read();
|
|
64
|
+
if (done) {
|
|
65
|
+
// Process any remaining buffer content
|
|
66
|
+
if (buffer.trim().length > 0) {
|
|
67
|
+
const event = parseSseEvent(buffer);
|
|
68
|
+
if (event) {
|
|
69
|
+
events.push(event);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break; // Exit loop when stream is done
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const decodedChunk = decoder.decode(value, { stream: true });
|
|
76
|
+
buffer += decodedChunk;
|
|
77
|
+
// Process buffer line by line, looking for complete messages (ending in \n\n)
|
|
78
|
+
let eventEndIndex;
|
|
79
|
+
while ((eventEndIndex = buffer.indexOf('\n\n')) !== -1) {
|
|
80
|
+
const message = buffer.substring(0, eventEndIndex);
|
|
81
|
+
buffer = buffer.substring(eventEndIndex + 2); // Consume message + \n\n
|
|
82
|
+
if (message.startsWith('data:')) {
|
|
83
|
+
const event = parseSseEvent(message);
|
|
84
|
+
if (event) {
|
|
85
|
+
events.push(event);
|
|
86
|
+
if (event.type === BRAIN_EVENTS.COMPLETE) {
|
|
87
|
+
reader.cancel(`Received terminal event: ${event.type}`);
|
|
88
|
+
return events;
|
|
89
|
+
}
|
|
90
|
+
if (event.type === BRAIN_EVENTS.ERROR) {
|
|
91
|
+
console.error(
|
|
92
|
+
'Received BRAIN_EVENTS.ERROR. Event details:',
|
|
93
|
+
event
|
|
94
|
+
);
|
|
95
|
+
reader.cancel(`Received terminal event: ${event.type}`);
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Received terminal event: ${
|
|
98
|
+
event.type
|
|
99
|
+
}. Details: ${JSON.stringify(event)}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return events;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it('POST /brains/runs without brainName should return 400', async () => {
|
|
110
|
+
const testEnv = env as TestEnv;
|
|
111
|
+
|
|
112
|
+
const request = new Request('http://example.com/brains/runs', {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({}), // Empty body, check for missing brainName
|
|
116
|
+
});
|
|
117
|
+
const context = createExecutionContext();
|
|
118
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
119
|
+
await waitOnExecutionContext(context);
|
|
120
|
+
|
|
121
|
+
expect(response.status).toBe(400);
|
|
122
|
+
const responseBody = await response.json();
|
|
123
|
+
expect(responseBody).toEqual({
|
|
124
|
+
error: 'Missing brainName in request body',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('POST /brains/runs with non-existent brain should return 404', async () => {
|
|
129
|
+
const testEnv = env as TestEnv;
|
|
130
|
+
const request = new Request('http://example.com/brains/runs', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({ brainName: 'non-existent-brain' }),
|
|
134
|
+
});
|
|
135
|
+
const context = createExecutionContext();
|
|
136
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
137
|
+
await waitOnExecutionContext(context);
|
|
138
|
+
expect(response.status).toBe(404);
|
|
139
|
+
const responseBody = await response.json();
|
|
140
|
+
expect(responseBody).toEqual({
|
|
141
|
+
error: "Brain 'non-existent-brain' not found",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('Create and watch a brain run', async () => {
|
|
146
|
+
const testEnv = env as TestEnv;
|
|
147
|
+
const brainName = 'basic-brain';
|
|
148
|
+
|
|
149
|
+
// --- Create the brain run ---
|
|
150
|
+
const request = new Request('http://example.com/brains/runs', {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ brainName }),
|
|
154
|
+
});
|
|
155
|
+
const context = createExecutionContext();
|
|
156
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
157
|
+
expect(response.status).toBe(201);
|
|
158
|
+
const responseBody = await response.json<{ brainRunId: string }>();
|
|
159
|
+
const brainRunId = responseBody.brainRunId;
|
|
160
|
+
await waitOnExecutionContext(context);
|
|
161
|
+
|
|
162
|
+
// --- Watch the brain run via SSE ---
|
|
163
|
+
const watchUrl = `http://example.com/brains/runs/${brainRunId}/watch`;
|
|
164
|
+
const watchRequest = new Request(watchUrl);
|
|
165
|
+
const watchContext = createExecutionContext();
|
|
166
|
+
const watchResponse = await worker.fetch(
|
|
167
|
+
watchRequest,
|
|
168
|
+
testEnv,
|
|
169
|
+
watchContext
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(watchResponse.status).toBe(200);
|
|
173
|
+
expect(watchResponse.headers.get('Content-Type')).toContain(
|
|
174
|
+
'text/event-stream'
|
|
175
|
+
);
|
|
176
|
+
if (!watchResponse.body) {
|
|
177
|
+
throw new Error('Watch response body is null');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Read all events from the SSE stream ---
|
|
181
|
+
const allEvents = await readSseStream(watchResponse.body);
|
|
182
|
+
|
|
183
|
+
// --- Assertions on the collected events ---
|
|
184
|
+
// Check for start event
|
|
185
|
+
const startEvent = allEvents.find(
|
|
186
|
+
(e): e is BrainStartEvent => e.type === BRAIN_EVENTS.START
|
|
187
|
+
);
|
|
188
|
+
expect(startEvent).toBeDefined();
|
|
189
|
+
expect(startEvent?.brainTitle).toBe(brainName);
|
|
190
|
+
expect(startEvent?.status).toBe(STATUS.RUNNING);
|
|
191
|
+
|
|
192
|
+
// Check for complete event
|
|
193
|
+
const completeEvent = allEvents.find(
|
|
194
|
+
(e): e is BrainCompleteEvent => e.type === BRAIN_EVENTS.COMPLETE
|
|
195
|
+
);
|
|
196
|
+
expect(completeEvent).toBeDefined();
|
|
197
|
+
expect(completeEvent?.status).toBe(STATUS.COMPLETE);
|
|
198
|
+
|
|
199
|
+
// Check the final step status event
|
|
200
|
+
const stepStatusEvents = allEvents.filter(
|
|
201
|
+
(e): e is StepStatusEvent => e.type === BRAIN_EVENTS.STEP_STATUS
|
|
202
|
+
);
|
|
203
|
+
expect(stepStatusEvents.length).toBeGreaterThan(0);
|
|
204
|
+
const lastStepStatusEvent = stepStatusEvents[stepStatusEvents.length - 1];
|
|
205
|
+
expect(
|
|
206
|
+
lastStepStatusEvent.steps.every(
|
|
207
|
+
(step: any) => step.status === STATUS.COMPLETE
|
|
208
|
+
)
|
|
209
|
+
).toBe(true);
|
|
210
|
+
|
|
211
|
+
// Check for specific step completion if needed (depends on basic-brain structure)
|
|
212
|
+
const stepCompleteEvents = allEvents.filter(
|
|
213
|
+
(e): e is StepCompletedEvent => e.type === BRAIN_EVENTS.STEP_COMPLETE
|
|
214
|
+
);
|
|
215
|
+
expect(stepCompleteEvents.length).toBeGreaterThanOrEqual(1); // Assuming basic-brain has at least one step
|
|
216
|
+
|
|
217
|
+
await waitOnExecutionContext(watchContext);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('Create and watch a delayed brain run', async () => {
|
|
221
|
+
const testEnv = env as TestEnv;
|
|
222
|
+
const brainName = 'delayed-brain';
|
|
223
|
+
|
|
224
|
+
// Create the brain run
|
|
225
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({ brainName }),
|
|
229
|
+
});
|
|
230
|
+
const createContext = createExecutionContext();
|
|
231
|
+
const createResponse = await worker.fetch(
|
|
232
|
+
createRequest,
|
|
233
|
+
testEnv,
|
|
234
|
+
createContext
|
|
235
|
+
);
|
|
236
|
+
expect(createResponse.status).toBe(201);
|
|
237
|
+
const createResponseBody = await createResponse.json<{
|
|
238
|
+
brainRunId: string;
|
|
239
|
+
}>();
|
|
240
|
+
const brainRunId = createResponseBody.brainRunId;
|
|
241
|
+
await waitOnExecutionContext(createContext);
|
|
242
|
+
|
|
243
|
+
// Watch the brain run via SSE
|
|
244
|
+
const watchUrl = `http://example.com/brains/runs/${brainRunId}/watch`;
|
|
245
|
+
const watchRequest = new Request(watchUrl);
|
|
246
|
+
const watchContext = createExecutionContext();
|
|
247
|
+
const watchResponse = await worker.fetch(
|
|
248
|
+
watchRequest,
|
|
249
|
+
testEnv,
|
|
250
|
+
watchContext
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(watchResponse.status).toBe(200);
|
|
254
|
+
expect(watchResponse.headers.get('Content-Type')).toContain(
|
|
255
|
+
'text/event-stream'
|
|
256
|
+
);
|
|
257
|
+
if (!watchResponse.body) {
|
|
258
|
+
throw new Error('Watch response body is null');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Read all events from the SSE stream ---
|
|
262
|
+
const allEvents = await readSseStream(watchResponse.body);
|
|
263
|
+
|
|
264
|
+
// --- Assertions on the collected events ---
|
|
265
|
+
// Check for start event
|
|
266
|
+
const startEvent = allEvents.find(
|
|
267
|
+
(e): e is BrainStartEvent => e.type === BRAIN_EVENTS.START
|
|
268
|
+
);
|
|
269
|
+
expect(startEvent).toBeDefined();
|
|
270
|
+
expect(startEvent?.brainTitle).toBe(brainName);
|
|
271
|
+
expect(startEvent?.status).toBe(STATUS.RUNNING);
|
|
272
|
+
|
|
273
|
+
// Check for step start/complete events for the delayed step
|
|
274
|
+
const delayStepStart = allEvents.find(
|
|
275
|
+
(e): e is StepStartedEvent =>
|
|
276
|
+
e.type === BRAIN_EVENTS.STEP_START && e.stepTitle === 'Start Delay'
|
|
277
|
+
);
|
|
278
|
+
expect(delayStepStart).toBeDefined();
|
|
279
|
+
const delayStepComplete = allEvents.find(
|
|
280
|
+
(e): e is StepCompletedEvent =>
|
|
281
|
+
e.type === BRAIN_EVENTS.STEP_COMPLETE && e.stepTitle === 'Start Delay'
|
|
282
|
+
);
|
|
283
|
+
expect(delayStepComplete).toBeDefined();
|
|
284
|
+
|
|
285
|
+
// Check for the final complete event
|
|
286
|
+
const completeEvent = allEvents.find(
|
|
287
|
+
(e): e is BrainCompleteEvent => e.type === BRAIN_EVENTS.COMPLETE
|
|
288
|
+
);
|
|
289
|
+
expect(completeEvent).toBeDefined();
|
|
290
|
+
expect(completeEvent?.status).toBe(STATUS.COMPLETE);
|
|
291
|
+
|
|
292
|
+
// Check the final step status event shows completion
|
|
293
|
+
const stepStatusEvents = allEvents.filter(
|
|
294
|
+
(e): e is StepStatusEvent => e.type === BRAIN_EVENTS.STEP_STATUS
|
|
295
|
+
);
|
|
296
|
+
expect(stepStatusEvents.length).toBeGreaterThan(0);
|
|
297
|
+
const lastStepStatusEvent = stepStatusEvents[stepStatusEvents.length - 1];
|
|
298
|
+
expect(
|
|
299
|
+
lastStepStatusEvent.steps.every(
|
|
300
|
+
(step: any) => step.status === STATUS.COMPLETE
|
|
301
|
+
)
|
|
302
|
+
).toBe(true);
|
|
303
|
+
|
|
304
|
+
await waitOnExecutionContext(watchContext);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('Asserts brainRunId is present in SSE events', async () => {
|
|
308
|
+
const testEnv = env as TestEnv;
|
|
309
|
+
const brainName = 'basic-brain';
|
|
310
|
+
|
|
311
|
+
// Create brain run
|
|
312
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
|
+
body: JSON.stringify({ brainName }),
|
|
316
|
+
});
|
|
317
|
+
const createContext = createExecutionContext();
|
|
318
|
+
const createResponse = await worker.fetch(
|
|
319
|
+
createRequest,
|
|
320
|
+
testEnv,
|
|
321
|
+
createContext
|
|
322
|
+
);
|
|
323
|
+
const createResponseBody = await createResponse.json<{
|
|
324
|
+
brainRunId: string;
|
|
325
|
+
}>();
|
|
326
|
+
const expectedBrainRunId = createResponseBody.brainRunId;
|
|
327
|
+
await waitOnExecutionContext(createContext);
|
|
328
|
+
|
|
329
|
+
// Watch brain run
|
|
330
|
+
const watchUrl = `http://example.com/brains/runs/${expectedBrainRunId}/watch`;
|
|
331
|
+
const watchRequest = new Request(watchUrl);
|
|
332
|
+
const watchContext = createExecutionContext();
|
|
333
|
+
const watchResponse = await worker.fetch(
|
|
334
|
+
watchRequest,
|
|
335
|
+
testEnv,
|
|
336
|
+
watchContext
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Get first event from stream
|
|
340
|
+
const reader = watchResponse.body?.getReader();
|
|
341
|
+
if (!reader) throw new Error('Watch response body is null');
|
|
342
|
+
|
|
343
|
+
const { value } = await reader.read();
|
|
344
|
+
const chunk = new TextDecoder().decode(value);
|
|
345
|
+
const event = parseSseEvent(chunk);
|
|
346
|
+
|
|
347
|
+
// Cleanup
|
|
348
|
+
reader.cancel();
|
|
349
|
+
await waitOnExecutionContext(watchContext);
|
|
350
|
+
|
|
351
|
+
// Assert
|
|
352
|
+
expect(event.brainRunId).toBeDefined();
|
|
353
|
+
expect(event.brainRunId).toBe(expectedBrainRunId);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('Monitor receives brain events (checking brain run)', async () => {
|
|
357
|
+
const testEnv = env as TestEnv;
|
|
358
|
+
const brainName = 'basic-brain';
|
|
359
|
+
|
|
360
|
+
// Start the brain run
|
|
361
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
headers: { 'Content-Type': 'application/json' },
|
|
364
|
+
body: JSON.stringify({ brainName }),
|
|
365
|
+
});
|
|
366
|
+
const createContext = createExecutionContext();
|
|
367
|
+
const createResponse = await worker.fetch(
|
|
368
|
+
createRequest,
|
|
369
|
+
testEnv,
|
|
370
|
+
createContext
|
|
371
|
+
);
|
|
372
|
+
const { brainRunId } = await createResponse.json<{ brainRunId: string }>();
|
|
373
|
+
await waitOnExecutionContext(createContext);
|
|
374
|
+
|
|
375
|
+
// Watch the brain run via SSE until completion
|
|
376
|
+
const watchUrl = `http://example.com/brains/runs/${brainRunId}/watch`;
|
|
377
|
+
const watchRequest = new Request(watchUrl);
|
|
378
|
+
const watchContext = createExecutionContext();
|
|
379
|
+
const watchResponse = await worker.fetch(
|
|
380
|
+
watchRequest,
|
|
381
|
+
testEnv,
|
|
382
|
+
watchContext
|
|
383
|
+
);
|
|
384
|
+
await readSseStream(watchResponse.body!);
|
|
385
|
+
await waitOnExecutionContext(watchContext);
|
|
386
|
+
|
|
387
|
+
// Get the monitor singleton instance
|
|
388
|
+
const monitorId = testEnv.MONITOR_DO.idFromName('singleton');
|
|
389
|
+
const monitorStub = testEnv.MONITOR_DO.get(monitorId);
|
|
390
|
+
const lastEvent = await monitorStub.getLastEvent(brainRunId);
|
|
391
|
+
|
|
392
|
+
// The last event should be a brain complete event
|
|
393
|
+
expect(lastEvent).toBeDefined();
|
|
394
|
+
expect(lastEvent.type).toBe(BRAIN_EVENTS.COMPLETE);
|
|
395
|
+
expect(lastEvent.status).toBe(STATUS.COMPLETE);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('Watches brain run as it runs', async () => {
|
|
399
|
+
const testEnv = env as TestEnv;
|
|
400
|
+
const brainName = 'basic-brain';
|
|
401
|
+
|
|
402
|
+
// Run the brain run twice
|
|
403
|
+
for (let i = 0; i < 2; i++) {
|
|
404
|
+
// Start the brain run
|
|
405
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
headers: { 'Content-Type': 'application/json' },
|
|
408
|
+
body: JSON.stringify({ brainName }),
|
|
409
|
+
});
|
|
410
|
+
const createContext = createExecutionContext();
|
|
411
|
+
const createResponse = await worker.fetch(
|
|
412
|
+
createRequest,
|
|
413
|
+
testEnv,
|
|
414
|
+
createContext
|
|
415
|
+
);
|
|
416
|
+
const { brainRunId } = await createResponse.json<{
|
|
417
|
+
brainRunId: string;
|
|
418
|
+
}>();
|
|
419
|
+
|
|
420
|
+
// Watch the brain run via SSE until completion
|
|
421
|
+
const watchUrl = `http://example.com/brains/runs/${brainRunId}/watch`;
|
|
422
|
+
const watchRequest = new Request(watchUrl);
|
|
423
|
+
const watchContext = createExecutionContext();
|
|
424
|
+
const watchResponse = await worker.fetch(
|
|
425
|
+
watchRequest,
|
|
426
|
+
testEnv,
|
|
427
|
+
watchContext
|
|
428
|
+
);
|
|
429
|
+
await readSseStream(watchResponse.body!);
|
|
430
|
+
await waitOnExecutionContext(watchContext);
|
|
431
|
+
await waitOnExecutionContext(createContext);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Get brain run history
|
|
435
|
+
const historyRequest = new Request(
|
|
436
|
+
`http://example.com/brains/${brainName}/history?limit=5`
|
|
437
|
+
);
|
|
438
|
+
const historyContext = createExecutionContext();
|
|
439
|
+
const historyResponse = await worker.fetch(
|
|
440
|
+
historyRequest,
|
|
441
|
+
testEnv,
|
|
442
|
+
historyContext
|
|
443
|
+
);
|
|
444
|
+
await waitOnExecutionContext(historyContext);
|
|
445
|
+
|
|
446
|
+
expect(historyResponse.status).toBe(200);
|
|
447
|
+
const history = await historyResponse.json<{
|
|
448
|
+
runs: Array<{
|
|
449
|
+
brainRunId: string;
|
|
450
|
+
brainTitle: string;
|
|
451
|
+
brainDescription: string | null;
|
|
452
|
+
type: string;
|
|
453
|
+
status: string;
|
|
454
|
+
options: string;
|
|
455
|
+
error: string | null;
|
|
456
|
+
createdAt: number;
|
|
457
|
+
startedAt: number | null;
|
|
458
|
+
completedAt: number | null;
|
|
459
|
+
}>;
|
|
460
|
+
}>();
|
|
461
|
+
expect(history.runs.length).toBe(2);
|
|
462
|
+
|
|
463
|
+
// Verify each run has the expected properties
|
|
464
|
+
for (const run of history.runs) {
|
|
465
|
+
expect(run).toHaveProperty('brainRunId');
|
|
466
|
+
expect(run).toHaveProperty('brainTitle');
|
|
467
|
+
expect(run).toHaveProperty('brainDescription');
|
|
468
|
+
expect(run).toHaveProperty('type');
|
|
469
|
+
expect(run).toHaveProperty('status');
|
|
470
|
+
expect(run).toHaveProperty('options');
|
|
471
|
+
expect(run).toHaveProperty('error');
|
|
472
|
+
expect(run).toHaveProperty('createdAt');
|
|
473
|
+
expect(run).toHaveProperty('startedAt');
|
|
474
|
+
expect(run).toHaveProperty('completedAt');
|
|
475
|
+
expect(run.status).toBe(STATUS.COMPLETE);
|
|
476
|
+
expect(run.brainTitle).toBe(brainName);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Verify runs are ordered by createdAt descending
|
|
480
|
+
const timestamps = history.runs.map(
|
|
481
|
+
(run: { createdAt: number }) => run.createdAt
|
|
482
|
+
);
|
|
483
|
+
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('Watch endpoint streams running brains', async () => {
|
|
487
|
+
const testEnv = env as TestEnv;
|
|
488
|
+
const brainName = 'delayed-brain';
|
|
489
|
+
const brainRuns: string[] = [];
|
|
490
|
+
|
|
491
|
+
// Start 3 delayed brains
|
|
492
|
+
for (let i = 0; i < 3; i++) {
|
|
493
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify({ brainName }),
|
|
497
|
+
});
|
|
498
|
+
const createContext = createExecutionContext();
|
|
499
|
+
const createResponse = await worker.fetch(
|
|
500
|
+
createRequest,
|
|
501
|
+
testEnv,
|
|
502
|
+
createContext
|
|
503
|
+
);
|
|
504
|
+
const { brainRunId } = await createResponse.json<{
|
|
505
|
+
brainRunId: string;
|
|
506
|
+
}>();
|
|
507
|
+
brainRuns.push(brainRunId);
|
|
508
|
+
await waitOnExecutionContext(createContext);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Connect to watch endpoint
|
|
512
|
+
const watchRequest = new Request('http://example.com/brains/watch');
|
|
513
|
+
const watchContext = createExecutionContext();
|
|
514
|
+
const watchResponse = await worker.fetch(
|
|
515
|
+
watchRequest,
|
|
516
|
+
testEnv,
|
|
517
|
+
watchContext
|
|
518
|
+
);
|
|
519
|
+
expect(watchResponse.status).toBe(200);
|
|
520
|
+
expect(watchResponse.headers.get('Content-Type')).toContain(
|
|
521
|
+
'text/event-stream'
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
if (!watchResponse.body) {
|
|
525
|
+
throw new Error('Watch response body is null');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Read the SSE stream
|
|
529
|
+
const events: any[] = [];
|
|
530
|
+
const reader = watchResponse.body.getReader();
|
|
531
|
+
const decoder = new TextDecoder();
|
|
532
|
+
let buffer = '';
|
|
533
|
+
|
|
534
|
+
// Helper to process SSE messages
|
|
535
|
+
const processBuffer = () => {
|
|
536
|
+
const messages = buffer.split('\n\n');
|
|
537
|
+
buffer = messages.pop() || ''; // Keep the incomplete message in the buffer
|
|
538
|
+
|
|
539
|
+
for (const message of messages) {
|
|
540
|
+
if (message.startsWith('data: ')) {
|
|
541
|
+
const data = JSON.parse(message.slice(6));
|
|
542
|
+
events.push(data);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Read for a while to capture brain completions
|
|
548
|
+
const startTime = Date.now();
|
|
549
|
+
const TIMEOUT = 5000; // 5 seconds should be enough for our test brains
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
while (Date.now() - startTime < TIMEOUT) {
|
|
553
|
+
const { value, done } = await reader.read();
|
|
554
|
+
if (done) break;
|
|
555
|
+
|
|
556
|
+
buffer += decoder.decode(value, { stream: true });
|
|
557
|
+
processBuffer();
|
|
558
|
+
|
|
559
|
+
// If we've seen all brains complete, we can stop early
|
|
560
|
+
const lastEvent = events[events.length - 1];
|
|
561
|
+
if (lastEvent?.runningBrains?.length === 0) {
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} finally {
|
|
566
|
+
reader.cancel();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Verify the events
|
|
570
|
+
expect(events.length).toBeGreaterThan(0);
|
|
571
|
+
|
|
572
|
+
// First event should show all brains running
|
|
573
|
+
const initialState = events[0];
|
|
574
|
+
expect(initialState.runningBrains).toBeDefined();
|
|
575
|
+
expect(initialState.runningBrains.length).toBe(3);
|
|
576
|
+
expect(
|
|
577
|
+
initialState.runningBrains.every((w: any) => w.status === STATUS.RUNNING)
|
|
578
|
+
).toBe(true);
|
|
579
|
+
|
|
580
|
+
// Last event should show no running brains
|
|
581
|
+
const finalState = events[events.length - 1];
|
|
582
|
+
expect(finalState.runningBrains).toBeDefined();
|
|
583
|
+
expect(finalState.runningBrains.length).toBe(0);
|
|
584
|
+
|
|
585
|
+
await waitOnExecutionContext(watchContext);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('Loads resources from the resource manifest', async () => {
|
|
589
|
+
const testEnv = env as TestEnv;
|
|
590
|
+
const brainName = 'resource-brain';
|
|
591
|
+
|
|
592
|
+
// First, set up test resources in R2
|
|
593
|
+
// Create testResource
|
|
594
|
+
await testEnv.RESOURCES_BUCKET.put(
|
|
595
|
+
'testResource.txt',
|
|
596
|
+
'This is a test resource',
|
|
597
|
+
{
|
|
598
|
+
customMetadata: {
|
|
599
|
+
type: 'text',
|
|
600
|
+
path: 'testResource.txt',
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// Create testResourceBinary
|
|
606
|
+
await testEnv.RESOURCES_BUCKET.put(
|
|
607
|
+
'testResourceBinary.bin',
|
|
608
|
+
'This is a test resource binary',
|
|
609
|
+
{
|
|
610
|
+
customMetadata: {
|
|
611
|
+
type: 'binary',
|
|
612
|
+
path: 'testResourceBinary.bin',
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Create nested resource
|
|
618
|
+
await testEnv.RESOURCES_BUCKET.put(
|
|
619
|
+
'nestedResource/testNestedResource.txt',
|
|
620
|
+
'This is a test resource',
|
|
621
|
+
{
|
|
622
|
+
customMetadata: {
|
|
623
|
+
type: 'text',
|
|
624
|
+
path: 'nestedResource/testNestedResource.txt',
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const createRequest = new Request('http://example.com/brains/runs', {
|
|
630
|
+
method: 'POST',
|
|
631
|
+
headers: { 'Content-Type': 'application/json' },
|
|
632
|
+
body: JSON.stringify({ brainName }),
|
|
633
|
+
});
|
|
634
|
+
const createContext = createExecutionContext();
|
|
635
|
+
const createResponse = await worker.fetch(
|
|
636
|
+
createRequest,
|
|
637
|
+
testEnv,
|
|
638
|
+
createContext
|
|
639
|
+
);
|
|
640
|
+
const { brainRunId } = await createResponse.json<{
|
|
641
|
+
brainRunId: string;
|
|
642
|
+
}>();
|
|
643
|
+
await waitOnExecutionContext(createContext);
|
|
644
|
+
|
|
645
|
+
// Watch the brain run via SSE until completion
|
|
646
|
+
const watchUrl = `http://example.com/brains/runs/${brainRunId}/watch`;
|
|
647
|
+
const watchRequest = new Request(watchUrl);
|
|
648
|
+
const watchContext = createExecutionContext();
|
|
649
|
+
const watchResponse = await worker.fetch(
|
|
650
|
+
watchRequest,
|
|
651
|
+
testEnv,
|
|
652
|
+
watchContext
|
|
653
|
+
);
|
|
654
|
+
expect(watchResponse.status).toBe(200); // Ensure watch connection is OK
|
|
655
|
+
if (!watchResponse.body) {
|
|
656
|
+
throw new Error('Watch response body is null');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// --- Read all events from the SSE stream ---
|
|
660
|
+
const allEvents = await readSseStream(watchResponse.body);
|
|
661
|
+
await waitOnExecutionContext(watchContext); // Wait for SSE stream processing and DOs to settle
|
|
662
|
+
|
|
663
|
+
// --- Assertions on the collected events ---
|
|
664
|
+
|
|
665
|
+
// Check for overall brain completion
|
|
666
|
+
const completeEvent = allEvents.find(
|
|
667
|
+
(e): e is BrainCompleteEvent => e.type === BRAIN_EVENTS.COMPLETE
|
|
668
|
+
);
|
|
669
|
+
expect(completeEvent).toBeDefined();
|
|
670
|
+
expect(completeEvent?.status).toBe(STATUS.COMPLETE);
|
|
671
|
+
|
|
672
|
+
// Find the step completion events
|
|
673
|
+
const stepCompleteEvents = allEvents.filter(
|
|
674
|
+
(e): e is StepCompletedEvent => e.type === BRAIN_EVENTS.STEP_COMPLETE
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
const loadTextStepCompleteEvent = stepCompleteEvents.find(
|
|
678
|
+
(e) => e.stepTitle === 'Load text resource'
|
|
679
|
+
);
|
|
680
|
+
expect(loadTextStepCompleteEvent).toBeDefined();
|
|
681
|
+
expect(loadTextStepCompleteEvent?.patch).toBeDefined();
|
|
682
|
+
|
|
683
|
+
const loadBinaryStepCompleteEvent = stepCompleteEvents.find(
|
|
684
|
+
(e) => e.stepTitle === 'Load binary resource'
|
|
685
|
+
);
|
|
686
|
+
expect(loadBinaryStepCompleteEvent).toBeDefined();
|
|
687
|
+
expect(loadBinaryStepCompleteEvent?.patch).toBeDefined();
|
|
688
|
+
|
|
689
|
+
// Expected resource content from packages/cloudflare/test-project/src/runner.ts
|
|
690
|
+
const expectedTextContent = 'This is a test resource';
|
|
691
|
+
const expectedBinaryContentRaw = 'This is a test resource binary';
|
|
692
|
+
const expectedBinaryContentBase64 = Buffer.from(
|
|
693
|
+
expectedBinaryContentRaw
|
|
694
|
+
).toString('base64');
|
|
695
|
+
|
|
696
|
+
// Verify the patch from 'Load text resource' step
|
|
697
|
+
// This patch is relative to the state *before* this step
|
|
698
|
+
const textPatch = loadTextStepCompleteEvent!.patch;
|
|
699
|
+
const addTextOp = textPatch.find(
|
|
700
|
+
(op) => op.op === 'add' && op.path === '/text'
|
|
701
|
+
);
|
|
702
|
+
expect(addTextOp).toBeDefined();
|
|
703
|
+
expect(addTextOp?.value).toBe(expectedTextContent);
|
|
704
|
+
|
|
705
|
+
// Verify the patch from 'Load binary resource' step
|
|
706
|
+
// This patch is relative to the state *after* 'Load text resource' step
|
|
707
|
+
const binaryPatch = loadBinaryStepCompleteEvent!.patch;
|
|
708
|
+
const addBufferOp = binaryPatch.find(
|
|
709
|
+
(op) => op.op === 'add' && op.path === '/buffer'
|
|
710
|
+
);
|
|
711
|
+
expect(addBufferOp).toBeDefined();
|
|
712
|
+
expect(addBufferOp?.value).toBe(expectedBinaryContentBase64);
|
|
713
|
+
|
|
714
|
+
// Verify the patch from 'Load nested resource' step
|
|
715
|
+
const loadNestedStepCompleteEvent = stepCompleteEvents.find(
|
|
716
|
+
(e) => e.stepTitle === 'Load nested resource'
|
|
717
|
+
);
|
|
718
|
+
expect(loadNestedStepCompleteEvent).toBeDefined();
|
|
719
|
+
expect(loadNestedStepCompleteEvent?.patch).toBeDefined();
|
|
720
|
+
const nestedTextPatch = loadNestedStepCompleteEvent!.patch;
|
|
721
|
+
const addNestedTextOp = nestedTextPatch.find(
|
|
722
|
+
(op) => op.op === 'add' && op.path === '/nestedText'
|
|
723
|
+
);
|
|
724
|
+
expect(addNestedTextOp).toBeDefined();
|
|
725
|
+
// The mock loader will return 'This is a test resource' for any text request.
|
|
726
|
+
expect(addNestedTextOp?.value).toBe(expectedTextContent);
|
|
727
|
+
|
|
728
|
+
// Check that the steps themselves are marked as completed in the final status
|
|
729
|
+
const stepStatusEvents = allEvents.filter(
|
|
730
|
+
(e): e is StepStatusEvent => e.type === BRAIN_EVENTS.STEP_STATUS
|
|
731
|
+
);
|
|
732
|
+
expect(stepStatusEvents.length).toBeGreaterThan(0);
|
|
733
|
+
const lastStepStatusEvent = stepStatusEvents[stepStatusEvents.length - 1];
|
|
734
|
+
|
|
735
|
+
expect(lastStepStatusEvent.steps.length).toBe(3); // resource-brain has 3 steps
|
|
736
|
+
const textStepFinalStatus = lastStepStatusEvent.steps.find(
|
|
737
|
+
(s) => s.title === 'Load text resource'
|
|
738
|
+
);
|
|
739
|
+
const binaryStepFinalStatus = lastStepStatusEvent.steps.find(
|
|
740
|
+
(s) => s.title === 'Load binary resource'
|
|
741
|
+
);
|
|
742
|
+
const nestedStepFinalStatus = lastStepStatusEvent.steps.find(
|
|
743
|
+
(s) => s.title === 'Load nested resource'
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
expect(textStepFinalStatus?.status).toBe(STATUS.COMPLETE);
|
|
747
|
+
expect(binaryStepFinalStatus?.status).toBe(STATUS.COMPLETE);
|
|
748
|
+
expect(nestedStepFinalStatus?.status).toBe(STATUS.COMPLETE);
|
|
749
|
+
|
|
750
|
+
// Clean up test resources
|
|
751
|
+
await testEnv.RESOURCES_BUCKET.delete('testResource.txt');
|
|
752
|
+
await testEnv.RESOURCES_BUCKET.delete('testResourceBinary.bin');
|
|
753
|
+
await testEnv.RESOURCES_BUCKET.delete(
|
|
754
|
+
'nestedResource/testNestedResource.txt'
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('Brain Schedules API Tests', () => {
|
|
759
|
+
it('POST /brains/schedules creates a new schedule', async () => {
|
|
760
|
+
const testEnv = env as TestEnv;
|
|
761
|
+
const brainName = 'basic-brain';
|
|
762
|
+
const cronExpression = '0 3 * * *'; // Daily at 3am
|
|
763
|
+
|
|
764
|
+
const request = new Request('http://example.com/brains/schedules', {
|
|
765
|
+
method: 'POST',
|
|
766
|
+
headers: { 'Content-Type': 'application/json' },
|
|
767
|
+
body: JSON.stringify({ brainName, cronExpression }),
|
|
768
|
+
});
|
|
769
|
+
const context = createExecutionContext();
|
|
770
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
771
|
+
await waitOnExecutionContext(context);
|
|
772
|
+
|
|
773
|
+
expect(response.status).toBe(201);
|
|
774
|
+
const responseBody = await response.json<{
|
|
775
|
+
id: string;
|
|
776
|
+
brainName: string;
|
|
777
|
+
cronExpression: string;
|
|
778
|
+
enabled: boolean;
|
|
779
|
+
createdAt: number;
|
|
780
|
+
}>();
|
|
781
|
+
|
|
782
|
+
expect(responseBody.id).toBeDefined();
|
|
783
|
+
expect(responseBody.brainName).toBe(brainName);
|
|
784
|
+
expect(responseBody.cronExpression).toBe(cronExpression);
|
|
785
|
+
expect(responseBody.enabled).toBe(true);
|
|
786
|
+
expect(responseBody.createdAt).toBeDefined();
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('GET /brains/schedules lists all schedules', async () => {
|
|
790
|
+
const testEnv = env as TestEnv;
|
|
791
|
+
|
|
792
|
+
// Create a few schedules first
|
|
793
|
+
for (let i = 0; i < 3; i++) {
|
|
794
|
+
const request = new Request('http://example.com/brains/schedules', {
|
|
795
|
+
method: 'POST',
|
|
796
|
+
headers: { 'Content-Type': 'application/json' },
|
|
797
|
+
body: JSON.stringify({
|
|
798
|
+
brainName: `brain-${i}`,
|
|
799
|
+
cronExpression: `${i} * * * *`,
|
|
800
|
+
}),
|
|
801
|
+
});
|
|
802
|
+
const context = createExecutionContext();
|
|
803
|
+
await worker.fetch(request, testEnv, context);
|
|
804
|
+
await waitOnExecutionContext(context);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// List schedules
|
|
808
|
+
const listRequest = new Request('http://example.com/brains/schedules');
|
|
809
|
+
const listContext = createExecutionContext();
|
|
810
|
+
const listResponse = await worker.fetch(
|
|
811
|
+
listRequest,
|
|
812
|
+
testEnv,
|
|
813
|
+
listContext
|
|
814
|
+
);
|
|
815
|
+
await waitOnExecutionContext(listContext);
|
|
816
|
+
|
|
817
|
+
expect(listResponse.status).toBe(200);
|
|
818
|
+
const responseBody = await listResponse.json<{
|
|
819
|
+
schedules: Array<{
|
|
820
|
+
id: string;
|
|
821
|
+
brainName: string;
|
|
822
|
+
cronExpression: string;
|
|
823
|
+
enabled: boolean;
|
|
824
|
+
createdAt: number;
|
|
825
|
+
}>;
|
|
826
|
+
count: number;
|
|
827
|
+
}>();
|
|
828
|
+
|
|
829
|
+
expect(responseBody.schedules).toBeInstanceOf(Array);
|
|
830
|
+
expect(responseBody.count).toBeGreaterThanOrEqual(3);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('DELETE /brains/schedules/:scheduleId deletes a schedule', async () => {
|
|
834
|
+
const testEnv = env as TestEnv;
|
|
835
|
+
|
|
836
|
+
// Create a schedule
|
|
837
|
+
const createRequest = new Request('http://example.com/brains/schedules', {
|
|
838
|
+
method: 'POST',
|
|
839
|
+
headers: { 'Content-Type': 'application/json' },
|
|
840
|
+
body: JSON.stringify({
|
|
841
|
+
brainName: 'delete-brain',
|
|
842
|
+
cronExpression: '0 0 * * *',
|
|
843
|
+
}),
|
|
844
|
+
});
|
|
845
|
+
const createContext = createExecutionContext();
|
|
846
|
+
const createResponse = await worker.fetch(
|
|
847
|
+
createRequest,
|
|
848
|
+
testEnv,
|
|
849
|
+
createContext
|
|
850
|
+
);
|
|
851
|
+
const { id } = await createResponse.json<{ id: string }>();
|
|
852
|
+
await waitOnExecutionContext(createContext);
|
|
853
|
+
|
|
854
|
+
// Delete the schedule
|
|
855
|
+
const deleteRequest = new Request(
|
|
856
|
+
`http://example.com/brains/schedules/${id}`,
|
|
857
|
+
{
|
|
858
|
+
method: 'DELETE',
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
const deleteContext = createExecutionContext();
|
|
862
|
+
const deleteResponse = await worker.fetch(
|
|
863
|
+
deleteRequest,
|
|
864
|
+
testEnv,
|
|
865
|
+
deleteContext
|
|
866
|
+
);
|
|
867
|
+
await waitOnExecutionContext(deleteContext);
|
|
868
|
+
|
|
869
|
+
expect(deleteResponse.status).toBe(204);
|
|
870
|
+
|
|
871
|
+
// Verify it's deleted
|
|
872
|
+
const getRequest = new Request(
|
|
873
|
+
`http://example.com/brains/schedules/${id}`
|
|
874
|
+
);
|
|
875
|
+
const getContext = createExecutionContext();
|
|
876
|
+
const getResponse = await worker.fetch(getRequest, testEnv, getContext);
|
|
877
|
+
await waitOnExecutionContext(getContext);
|
|
878
|
+
|
|
879
|
+
expect(getResponse.status).toBe(404);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('GET /brains/schedules/runs lists scheduled run history', async () => {
|
|
883
|
+
const testEnv = env as TestEnv;
|
|
884
|
+
|
|
885
|
+
const request = new Request('http://example.com/brains/schedules/runs');
|
|
886
|
+
const context = createExecutionContext();
|
|
887
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
888
|
+
await waitOnExecutionContext(context);
|
|
889
|
+
|
|
890
|
+
expect(response.status).toBe(200);
|
|
891
|
+
const responseBody = await response.json<{
|
|
892
|
+
runs: Array<{
|
|
893
|
+
id: number;
|
|
894
|
+
scheduleId: string;
|
|
895
|
+
brainRunId?: string;
|
|
896
|
+
status: 'triggered' | 'failed';
|
|
897
|
+
ranAt: number;
|
|
898
|
+
}>;
|
|
899
|
+
count: number;
|
|
900
|
+
}>();
|
|
901
|
+
|
|
902
|
+
expect(responseBody.runs).toBeInstanceOf(Array);
|
|
903
|
+
expect(typeof responseBody.count).toBe('number');
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('GET /brains/schedules/runs with scheduleId filter', async () => {
|
|
907
|
+
const testEnv = env as TestEnv;
|
|
908
|
+
const scheduleId = 'test-schedule-123';
|
|
909
|
+
|
|
910
|
+
const request = new Request(
|
|
911
|
+
`http://example.com/brains/schedules/runs?scheduleId=${scheduleId}`
|
|
912
|
+
);
|
|
913
|
+
const context = createExecutionContext();
|
|
914
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
915
|
+
await waitOnExecutionContext(context);
|
|
916
|
+
|
|
917
|
+
expect(response.status).toBe(200);
|
|
918
|
+
const responseBody = await response.json<{
|
|
919
|
+
runs: Array<{
|
|
920
|
+
id: number;
|
|
921
|
+
scheduleId: string;
|
|
922
|
+
brainRunId?: string;
|
|
923
|
+
status: 'triggered' | 'failed';
|
|
924
|
+
ranAt: number;
|
|
925
|
+
}>;
|
|
926
|
+
count: number;
|
|
927
|
+
}>();
|
|
928
|
+
|
|
929
|
+
// All runs should belong to the specified schedule
|
|
930
|
+
for (const run of responseBody.runs) {
|
|
931
|
+
expect(run.scheduleId).toBe(scheduleId);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('POST /brains/schedules validates cron expression', async () => {
|
|
936
|
+
const testEnv = env as TestEnv;
|
|
937
|
+
|
|
938
|
+
const request = new Request('http://example.com/brains/schedules', {
|
|
939
|
+
method: 'POST',
|
|
940
|
+
headers: { 'Content-Type': 'application/json' },
|
|
941
|
+
body: JSON.stringify({
|
|
942
|
+
brainName: 'invalid-cron-brain',
|
|
943
|
+
cronExpression: 'invalid cron',
|
|
944
|
+
}),
|
|
945
|
+
});
|
|
946
|
+
const context = createExecutionContext();
|
|
947
|
+
const response = await worker.fetch(request, testEnv, context);
|
|
948
|
+
await waitOnExecutionContext(context);
|
|
949
|
+
|
|
950
|
+
expect(response.status).toBe(400);
|
|
951
|
+
const error = (await response.json()) as { error: string };
|
|
952
|
+
expect(error.error).toContain('Invalid cron expression');
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('POST /brains/schedules allows multiple schedules per brain', async () => {
|
|
956
|
+
const testEnv = env as TestEnv;
|
|
957
|
+
const brainName = 'multi-schedule-brain';
|
|
958
|
+
|
|
959
|
+
// Create first schedule
|
|
960
|
+
const request1 = new Request('http://example.com/brains/schedules', {
|
|
961
|
+
method: 'POST',
|
|
962
|
+
headers: { 'Content-Type': 'application/json' },
|
|
963
|
+
body: JSON.stringify({
|
|
964
|
+
brainName,
|
|
965
|
+
cronExpression: '0 9 * * *', // 9am daily
|
|
966
|
+
}),
|
|
967
|
+
});
|
|
968
|
+
const context1 = createExecutionContext();
|
|
969
|
+
const response1 = await worker.fetch(request1, testEnv, context1);
|
|
970
|
+
await waitOnExecutionContext(context1);
|
|
971
|
+
expect(response1.status).toBe(201);
|
|
972
|
+
|
|
973
|
+
// Create second schedule for same brain
|
|
974
|
+
const request2 = new Request('http://example.com/brains/schedules', {
|
|
975
|
+
method: 'POST',
|
|
976
|
+
headers: { 'Content-Type': 'application/json' },
|
|
977
|
+
body: JSON.stringify({
|
|
978
|
+
brainName,
|
|
979
|
+
cronExpression: '0 17 * * *', // 5pm daily
|
|
980
|
+
}),
|
|
981
|
+
});
|
|
982
|
+
const context2 = createExecutionContext();
|
|
983
|
+
const response2 = await worker.fetch(request2, testEnv, context2);
|
|
984
|
+
await waitOnExecutionContext(context2);
|
|
985
|
+
expect(response2.status).toBe(201);
|
|
986
|
+
|
|
987
|
+
// Verify both schedules exist
|
|
988
|
+
const listRequest = new Request('http://example.com/brains/schedules');
|
|
989
|
+
const listContext = createExecutionContext();
|
|
990
|
+
const listResponse = await worker.fetch(
|
|
991
|
+
listRequest,
|
|
992
|
+
testEnv,
|
|
993
|
+
listContext
|
|
994
|
+
);
|
|
995
|
+
await waitOnExecutionContext(listContext);
|
|
996
|
+
|
|
997
|
+
const { schedules } = await listResponse.json<{
|
|
998
|
+
schedules: Array<{ brainName: string }>;
|
|
999
|
+
}>();
|
|
1000
|
+
|
|
1001
|
+
const multiSchedules = schedules.filter((s) => s.brainName === brainName);
|
|
1002
|
+
expect(multiSchedules.length).toBe(2);
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
});
|