@electric-ax/agents-server-conformance-tests 0.1.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/LICENSE +177 -0
- package/dist/index.cjs +3750 -0
- package/dist/index.d.cts +540 -0
- package/dist/index.d.ts +540 -0
- package/dist/index.js +3713 -0
- package/package.json +58 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3750 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
//#region rolldown:runtime
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
const vitest = __toESM(require("vitest"));
|
|
26
|
+
const fast_check = __toESM(require("fast-check"));
|
|
27
|
+
const node_http = __toESM(require("node:http"));
|
|
28
|
+
const __electric_sql_client = __toESM(require("@electric-sql/client"));
|
|
29
|
+
const node_child_process = __toESM(require("node:child_process"));
|
|
30
|
+
|
|
31
|
+
//#region src/electric-agents-dsl.ts
|
|
32
|
+
async function fetchShapeRows(baseUrl, table) {
|
|
33
|
+
const stream = new __electric_sql_client.ShapeStream({
|
|
34
|
+
url: `${baseUrl}/_electric/electric/v1/shape`,
|
|
35
|
+
params: { table },
|
|
36
|
+
subscribe: false
|
|
37
|
+
});
|
|
38
|
+
const shape = new __electric_sql_client.Shape(stream);
|
|
39
|
+
const rows = await shape.rows;
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
async function fetchEntitiesViaShape(baseUrl, filter) {
|
|
43
|
+
let entities = await fetchShapeRows(baseUrl, `entities`);
|
|
44
|
+
if (filter?.type) entities = entities.filter((e) => e.type === filter.type);
|
|
45
|
+
if (filter?.status) entities = entities.filter((e) => e.status === filter.status);
|
|
46
|
+
if (filter?.parent) entities = entities.filter((e) => e.parent === filter.parent);
|
|
47
|
+
return entities;
|
|
48
|
+
}
|
|
49
|
+
function toServerEntityTypeRegistration(registration) {
|
|
50
|
+
const body = {
|
|
51
|
+
name: registration.name,
|
|
52
|
+
description: registration.description,
|
|
53
|
+
...registration.creation_schema && { creation_schema: registration.creation_schema },
|
|
54
|
+
...registration.metadata_schema && { metadata_schema: registration.metadata_schema },
|
|
55
|
+
...registration.serve_endpoint && { serve_endpoint: registration.serve_endpoint }
|
|
56
|
+
};
|
|
57
|
+
const inboxSchemas = registration.inbox_schemas ?? registration.input_schemas;
|
|
58
|
+
const stateSchemas = registration.state_schemas ?? registration.output_schemas;
|
|
59
|
+
if (inboxSchemas) body.inbox_schemas = inboxSchemas;
|
|
60
|
+
if (stateSchemas) body.state_schemas = stateSchemas;
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
function normalizeWebhookPayload(body) {
|
|
64
|
+
const parsed = JSON.parse(body);
|
|
65
|
+
return {
|
|
66
|
+
consumer_id: String(parsed.consumer_id ?? parsed.consumerId ?? ``),
|
|
67
|
+
epoch: Number(parsed.epoch ?? 0),
|
|
68
|
+
wake_id: String(parsed.wake_id ?? parsed.wakeId ?? ``),
|
|
69
|
+
primary_stream: String(parsed.primary_stream ?? parsed.streamPath ?? ``),
|
|
70
|
+
streams: Array.isArray(parsed.streams) ? parsed.streams : [],
|
|
71
|
+
triggered_by: Array.isArray(parsed.triggered_by) ? parsed.triggered_by : Array.isArray(parsed.triggeredBy) ? parsed.triggeredBy : [],
|
|
72
|
+
callback: String(parsed.callback ?? ``),
|
|
73
|
+
token: String(parsed.token ?? parsed.claimToken ?? ``),
|
|
74
|
+
entity: parsed.entity,
|
|
75
|
+
trigger_event: parsed.trigger_event ?? parsed.triggerEvent
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
var WebhookReceiver = class {
|
|
79
|
+
server = null;
|
|
80
|
+
_url = null;
|
|
81
|
+
notifications = [];
|
|
82
|
+
waitResolvers = [];
|
|
83
|
+
consumedCount = 0;
|
|
84
|
+
async start() {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
this.server = (0, node_http.createServer)((req, res) => {
|
|
87
|
+
this.handleRequest(req, res);
|
|
88
|
+
});
|
|
89
|
+
this.server.on(`error`, reject);
|
|
90
|
+
this.server.listen(0, `127.0.0.1`, () => {
|
|
91
|
+
const addr = this.server.address();
|
|
92
|
+
if (typeof addr === `object` && addr) this._url = `http://127.0.0.1:${addr.port}`;
|
|
93
|
+
resolve(this._url);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async stop() {
|
|
98
|
+
if (!this.server) return;
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
this.server.closeAllConnections();
|
|
101
|
+
this.server.close(() => {
|
|
102
|
+
this.server = null;
|
|
103
|
+
this._url = null;
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
get url() {
|
|
109
|
+
if (!this._url) throw new Error(`WebhookReceiver not started`);
|
|
110
|
+
return this._url;
|
|
111
|
+
}
|
|
112
|
+
handleRequest(req, res) {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
req.on(`data`, (chunk) => chunks.push(chunk));
|
|
115
|
+
req.on(`end`, () => {
|
|
116
|
+
const body = Buffer.concat(chunks).toString(`utf-8`);
|
|
117
|
+
try {
|
|
118
|
+
const parsed = normalizeWebhookPayload(body);
|
|
119
|
+
const notification = {
|
|
120
|
+
body,
|
|
121
|
+
parsed,
|
|
122
|
+
resolve: (response) => {
|
|
123
|
+
res.writeHead(response.status, { "content-type": `application/json` });
|
|
124
|
+
res.end(response.body);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
this.notifications.push(notification);
|
|
128
|
+
for (const waiter of this.waitResolvers) waiter();
|
|
129
|
+
this.waitResolvers = [];
|
|
130
|
+
} catch {
|
|
131
|
+
res.writeHead(400);
|
|
132
|
+
res.end(`Invalid JSON`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async waitForNotification(timeoutMs = 1e4) {
|
|
137
|
+
const targetIdx = this.consumedCount;
|
|
138
|
+
this.consumedCount++;
|
|
139
|
+
if (this.notifications.length > targetIdx) return this.notifications[targetIdx];
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timeout = setTimeout(() => {
|
|
142
|
+
reject(new Error(`Timed out waiting for webhook notification after ${timeoutMs}ms`));
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
const check = () => {
|
|
145
|
+
if (this.notifications.length > targetIdx) {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
resolve(this.notifications[targetIdx]);
|
|
148
|
+
} else this.waitResolvers.push(check);
|
|
149
|
+
};
|
|
150
|
+
check();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async expectNoNotification(timeoutMs = 500) {
|
|
154
|
+
const startCount = this.notifications.length;
|
|
155
|
+
await new Promise((r) => setTimeout(r, timeoutMs));
|
|
156
|
+
(0, vitest.expect)(this.notifications.length).toBe(startCount);
|
|
157
|
+
}
|
|
158
|
+
get received() {
|
|
159
|
+
return this.notifications;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var ServeEndpointReceiver = class {
|
|
163
|
+
server = null;
|
|
164
|
+
_url = null;
|
|
165
|
+
manifest = null;
|
|
166
|
+
async start(manifest) {
|
|
167
|
+
this.manifest = manifest;
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
this.server = (0, node_http.createServer)((req, res) => {
|
|
170
|
+
this.handleRequest(req, res);
|
|
171
|
+
});
|
|
172
|
+
this.server.on(`error`, reject);
|
|
173
|
+
this.server.listen(0, `127.0.0.1`, () => {
|
|
174
|
+
const addr = this.server.address();
|
|
175
|
+
if (typeof addr === `object` && addr) this._url = `http://127.0.0.1:${addr.port}`;
|
|
176
|
+
resolve(this._url);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async stop() {
|
|
181
|
+
if (!this.server) return;
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
this.server.closeAllConnections();
|
|
184
|
+
this.server.close(() => {
|
|
185
|
+
this.server = null;
|
|
186
|
+
this._url = null;
|
|
187
|
+
resolve();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
get url() {
|
|
192
|
+
if (!this._url) throw new Error(`ServeEndpointReceiver not started`);
|
|
193
|
+
return this._url;
|
|
194
|
+
}
|
|
195
|
+
handleRequest(_req, res) {
|
|
196
|
+
res.writeHead(200, { "content-type": `application/json` });
|
|
197
|
+
res.end(JSON.stringify(this.manifest));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
async function electricAgentsFetch$1(baseUrl, path, opts = {}) {
|
|
201
|
+
return fetch(`${baseUrl}${routeControlPlanePath$1(path)}`, {
|
|
202
|
+
...opts,
|
|
203
|
+
headers: {
|
|
204
|
+
"content-type": `application/json`,
|
|
205
|
+
...opts.headers
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function routeControlPlanePath$1(path) {
|
|
210
|
+
const pathname = path.split(`?`, 1)[0];
|
|
211
|
+
if (pathname.startsWith(`/_electric/`) || isEntityStreamPath$1(pathname)) return path;
|
|
212
|
+
return `/_electric/entities${path}`;
|
|
213
|
+
}
|
|
214
|
+
function isEntityStreamPath$1(pathname) {
|
|
215
|
+
const segments = pathname.split(`/`).filter(Boolean);
|
|
216
|
+
const lastSegment = segments.at(-1);
|
|
217
|
+
return segments.length >= 3 && (lastSegment === `main` || lastSegment === `error`);
|
|
218
|
+
}
|
|
219
|
+
function subscriptionEndpoint$1(baseUrl, id) {
|
|
220
|
+
return `${baseUrl}/v1/stream-meta/subscriptions/${encodeURIComponent(id)}`;
|
|
221
|
+
}
|
|
222
|
+
function subscriptionPattern$1(pattern) {
|
|
223
|
+
return pattern.replace(/^\/+/, ``);
|
|
224
|
+
}
|
|
225
|
+
var ElectricAgentsScenario = class {
|
|
226
|
+
baseUrl;
|
|
227
|
+
steps = [];
|
|
228
|
+
_skipInvariants = false;
|
|
229
|
+
constructor(baseUrl) {
|
|
230
|
+
this.baseUrl = baseUrl;
|
|
231
|
+
}
|
|
232
|
+
subscription(pattern, id) {
|
|
233
|
+
this.steps.push({
|
|
234
|
+
kind: `subscription`,
|
|
235
|
+
pattern,
|
|
236
|
+
id
|
|
237
|
+
});
|
|
238
|
+
return this;
|
|
239
|
+
}
|
|
240
|
+
spawn(typeName, instanceId, opts) {
|
|
241
|
+
this.steps.push({
|
|
242
|
+
kind: `spawnTyped`,
|
|
243
|
+
typeName,
|
|
244
|
+
instanceId,
|
|
245
|
+
args: opts?.args,
|
|
246
|
+
tags: opts?.tags,
|
|
247
|
+
parent: opts?.parent,
|
|
248
|
+
initialMessage: opts?.initialMessage
|
|
249
|
+
});
|
|
250
|
+
return this;
|
|
251
|
+
}
|
|
252
|
+
send(payload, opts) {
|
|
253
|
+
this.steps.push({
|
|
254
|
+
kind: `send`,
|
|
255
|
+
payload,
|
|
256
|
+
from: opts.from,
|
|
257
|
+
type: opts.type
|
|
258
|
+
});
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
sendTo(url, payload, opts) {
|
|
262
|
+
this.steps.push({
|
|
263
|
+
kind: `sendTo`,
|
|
264
|
+
url,
|
|
265
|
+
payload,
|
|
266
|
+
from: opts.from
|
|
267
|
+
});
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
kill() {
|
|
271
|
+
this.steps.push({ kind: `kill` });
|
|
272
|
+
return this;
|
|
273
|
+
}
|
|
274
|
+
killUrl(url) {
|
|
275
|
+
this.steps.push({
|
|
276
|
+
kind: `killUrl`,
|
|
277
|
+
url
|
|
278
|
+
});
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
expectWebhook(opts) {
|
|
282
|
+
this.steps.push({
|
|
283
|
+
kind: `expectWebhook`,
|
|
284
|
+
opts
|
|
285
|
+
});
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
respondDone() {
|
|
289
|
+
this.steps.push({ kind: `respondDone` });
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
expectEntityContext(checks) {
|
|
293
|
+
this.steps.push({
|
|
294
|
+
kind: `expectEntityContext`,
|
|
295
|
+
checks
|
|
296
|
+
});
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
expectStatus(status) {
|
|
300
|
+
this.steps.push({
|
|
301
|
+
kind: `expectStatus`,
|
|
302
|
+
status
|
|
303
|
+
});
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
expectStreamContains(messageType) {
|
|
307
|
+
this.steps.push({
|
|
308
|
+
kind: `expectStreamContains`,
|
|
309
|
+
messageType
|
|
310
|
+
});
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
readStream(stream) {
|
|
314
|
+
this.steps.push({
|
|
315
|
+
kind: `readStream`,
|
|
316
|
+
stream
|
|
317
|
+
});
|
|
318
|
+
return this;
|
|
319
|
+
}
|
|
320
|
+
list(filter) {
|
|
321
|
+
this.steps.push({
|
|
322
|
+
kind: `list`,
|
|
323
|
+
filter
|
|
324
|
+
});
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
expectListCount(opts) {
|
|
328
|
+
this.steps.push({
|
|
329
|
+
kind: `expectListCount`,
|
|
330
|
+
...opts
|
|
331
|
+
});
|
|
332
|
+
return this;
|
|
333
|
+
}
|
|
334
|
+
expectListTotal(total) {
|
|
335
|
+
this.steps.push({
|
|
336
|
+
kind: `expectListTotal`,
|
|
337
|
+
total
|
|
338
|
+
});
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
registerType(registration) {
|
|
342
|
+
this.steps.push({
|
|
343
|
+
kind: `registerType`,
|
|
344
|
+
registration
|
|
345
|
+
});
|
|
346
|
+
return this;
|
|
347
|
+
}
|
|
348
|
+
expectTypeExists(name) {
|
|
349
|
+
this.steps.push({
|
|
350
|
+
kind: `expectTypeExists`,
|
|
351
|
+
name
|
|
352
|
+
});
|
|
353
|
+
return this;
|
|
354
|
+
}
|
|
355
|
+
inspectType(name) {
|
|
356
|
+
this.steps.push({
|
|
357
|
+
kind: `inspectType`,
|
|
358
|
+
name
|
|
359
|
+
});
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
362
|
+
deleteType(name) {
|
|
363
|
+
this.steps.push({
|
|
364
|
+
kind: `deleteType`,
|
|
365
|
+
name
|
|
366
|
+
});
|
|
367
|
+
return this;
|
|
368
|
+
}
|
|
369
|
+
expectTypeNotExists(name) {
|
|
370
|
+
this.steps.push({
|
|
371
|
+
kind: `expectTypeNotExists`,
|
|
372
|
+
name
|
|
373
|
+
});
|
|
374
|
+
return this;
|
|
375
|
+
}
|
|
376
|
+
amendSchemas(name, schemas) {
|
|
377
|
+
this.steps.push({
|
|
378
|
+
kind: `amendSchemas`,
|
|
379
|
+
name,
|
|
380
|
+
input_schemas: schemas.input_schemas,
|
|
381
|
+
output_schemas: schemas.output_schemas
|
|
382
|
+
});
|
|
383
|
+
return this;
|
|
384
|
+
}
|
|
385
|
+
listTypes() {
|
|
386
|
+
this.steps.push({ kind: `listTypes` });
|
|
387
|
+
return this;
|
|
388
|
+
}
|
|
389
|
+
registerTypeViaServe(registration) {
|
|
390
|
+
this.steps.push({
|
|
391
|
+
kind: `registerTypeViaServe`,
|
|
392
|
+
registration
|
|
393
|
+
});
|
|
394
|
+
return this;
|
|
395
|
+
}
|
|
396
|
+
write(payload, opts) {
|
|
397
|
+
this.steps.push({
|
|
398
|
+
kind: `write`,
|
|
399
|
+
payload,
|
|
400
|
+
eventType: opts?.type
|
|
401
|
+
});
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
writeStateProtocol(event) {
|
|
405
|
+
this.steps.push({
|
|
406
|
+
kind: `writeStateProtocol`,
|
|
407
|
+
event
|
|
408
|
+
});
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
expectStreamEvent(type, key, operation, valueCheck) {
|
|
412
|
+
this.steps.push({
|
|
413
|
+
kind: `expectStreamEvent`,
|
|
414
|
+
type,
|
|
415
|
+
key,
|
|
416
|
+
operation,
|
|
417
|
+
valueCheck
|
|
418
|
+
});
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
expectStreamEventCount(type, count) {
|
|
422
|
+
this.steps.push({
|
|
423
|
+
kind: `expectStreamEventCount`,
|
|
424
|
+
type,
|
|
425
|
+
count
|
|
426
|
+
});
|
|
427
|
+
return this;
|
|
428
|
+
}
|
|
429
|
+
setTags(tags) {
|
|
430
|
+
this.steps.push({
|
|
431
|
+
kind: `setTags`,
|
|
432
|
+
tags
|
|
433
|
+
});
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
expectTags(expected) {
|
|
437
|
+
this.steps.push({
|
|
438
|
+
kind: `expectTags`,
|
|
439
|
+
tags: expected
|
|
440
|
+
});
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
expectSpawnSchemaError(typeName, instanceId, opts) {
|
|
444
|
+
this.steps.push({
|
|
445
|
+
kind: `expectSpawnSchemaError`,
|
|
446
|
+
typeName,
|
|
447
|
+
instanceId,
|
|
448
|
+
args: opts?.args,
|
|
449
|
+
code: `SCHEMA_VALIDATION_ERROR`,
|
|
450
|
+
status: 422
|
|
451
|
+
});
|
|
452
|
+
return this;
|
|
453
|
+
}
|
|
454
|
+
expectSendSchemaError(payload, opts) {
|
|
455
|
+
this.steps.push({
|
|
456
|
+
kind: `expectSendSchemaError`,
|
|
457
|
+
payload,
|
|
458
|
+
messageType: opts.type ?? `default`,
|
|
459
|
+
code: `SCHEMA_VALIDATION_ERROR`,
|
|
460
|
+
status: 422
|
|
461
|
+
});
|
|
462
|
+
return this;
|
|
463
|
+
}
|
|
464
|
+
expectWriteSchemaError(payload, opts) {
|
|
465
|
+
this.steps.push({
|
|
466
|
+
kind: `expectWriteSchemaError`,
|
|
467
|
+
payload,
|
|
468
|
+
eventType: opts?.type ?? `default`,
|
|
469
|
+
code: `SCHEMA_VALIDATION_ERROR`,
|
|
470
|
+
status: 422
|
|
471
|
+
});
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
474
|
+
expectSendUnknownType(payload, opts) {
|
|
475
|
+
this.steps.push({
|
|
476
|
+
kind: `expectSendUnknownType`,
|
|
477
|
+
payload,
|
|
478
|
+
messageType: opts.type,
|
|
479
|
+
code: `UNKNOWN_MESSAGE_TYPE`,
|
|
480
|
+
status: 422
|
|
481
|
+
});
|
|
482
|
+
return this;
|
|
483
|
+
}
|
|
484
|
+
expectWriteUnknownType(payload, opts) {
|
|
485
|
+
this.steps.push({
|
|
486
|
+
kind: `expectWriteUnknownType`,
|
|
487
|
+
payload,
|
|
488
|
+
eventType: opts.type,
|
|
489
|
+
code: `UNKNOWN_EVENT_TYPE`,
|
|
490
|
+
status: 422
|
|
491
|
+
});
|
|
492
|
+
return this;
|
|
493
|
+
}
|
|
494
|
+
expectEntityPersisted() {
|
|
495
|
+
this.steps.push({ kind: `expectEntityPersisted` });
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
expectSpawnError(url, code, status) {
|
|
499
|
+
this.steps.push({
|
|
500
|
+
kind: `expectSpawnError`,
|
|
501
|
+
url,
|
|
502
|
+
code,
|
|
503
|
+
status
|
|
504
|
+
});
|
|
505
|
+
return this;
|
|
506
|
+
}
|
|
507
|
+
expectSendError(code, status) {
|
|
508
|
+
this.steps.push({
|
|
509
|
+
kind: `expectSendError`,
|
|
510
|
+
code,
|
|
511
|
+
status
|
|
512
|
+
});
|
|
513
|
+
return this;
|
|
514
|
+
}
|
|
515
|
+
custom(fn) {
|
|
516
|
+
this.steps.push({
|
|
517
|
+
kind: `custom`,
|
|
518
|
+
fn
|
|
519
|
+
});
|
|
520
|
+
return this;
|
|
521
|
+
}
|
|
522
|
+
wait(ms) {
|
|
523
|
+
this.steps.push({
|
|
524
|
+
kind: `wait`,
|
|
525
|
+
ms
|
|
526
|
+
});
|
|
527
|
+
return this;
|
|
528
|
+
}
|
|
529
|
+
skipInvariants() {
|
|
530
|
+
this._skipInvariants = true;
|
|
531
|
+
return this;
|
|
532
|
+
}
|
|
533
|
+
async run() {
|
|
534
|
+
const receiver = new WebhookReceiver();
|
|
535
|
+
await receiver.start();
|
|
536
|
+
const ctx = {
|
|
537
|
+
baseUrl: this.baseUrl,
|
|
538
|
+
receiver,
|
|
539
|
+
history: [],
|
|
540
|
+
subscriptions: [],
|
|
541
|
+
currentEntityUrl: null,
|
|
542
|
+
currentEntityStreams: null,
|
|
543
|
+
currentWriteToken: null,
|
|
544
|
+
notification: null,
|
|
545
|
+
lastListResult: null,
|
|
546
|
+
lastListTotal: null,
|
|
547
|
+
lastStreamMessages: null,
|
|
548
|
+
currentEntityType: null,
|
|
549
|
+
lastTypeResult: null,
|
|
550
|
+
lastTypeListResult: null,
|
|
551
|
+
serveReceiver: null
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
for (const step of this.steps) await executeStep(ctx, step);
|
|
555
|
+
if (!this._skipInvariants) checkInvariants(ctx.history);
|
|
556
|
+
return ctx.history;
|
|
557
|
+
} finally {
|
|
558
|
+
for (const subscription of ctx.subscriptions) try {
|
|
559
|
+
await fetch(subscriptionEndpoint$1(ctx.baseUrl, subscription.id), { method: `DELETE` });
|
|
560
|
+
} catch {}
|
|
561
|
+
for (const n of receiver.received) try {
|
|
562
|
+
n.resolve({
|
|
563
|
+
status: 200,
|
|
564
|
+
body: JSON.stringify({ done: true })
|
|
565
|
+
});
|
|
566
|
+
} catch {}
|
|
567
|
+
await receiver.stop();
|
|
568
|
+
if (ctx.serveReceiver) await ctx.serveReceiver.stop();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
async function executeStep(ctx, step) {
|
|
573
|
+
switch (step.kind) {
|
|
574
|
+
case `subscription`: {
|
|
575
|
+
const res = await fetch(subscriptionEndpoint$1(ctx.baseUrl, step.id), {
|
|
576
|
+
method: `PUT`,
|
|
577
|
+
headers: { "content-type": `application/json` },
|
|
578
|
+
body: JSON.stringify({
|
|
579
|
+
type: `webhook`,
|
|
580
|
+
pattern: subscriptionPattern$1(step.pattern),
|
|
581
|
+
webhook: { url: ctx.receiver.url }
|
|
582
|
+
})
|
|
583
|
+
});
|
|
584
|
+
(0, vitest.expect)(res.status).toBeLessThan(300);
|
|
585
|
+
ctx.subscriptions.push({
|
|
586
|
+
pattern: step.pattern,
|
|
587
|
+
id: step.id
|
|
588
|
+
});
|
|
589
|
+
ctx.history.push({
|
|
590
|
+
type: `subscription_created`,
|
|
591
|
+
pattern: step.pattern,
|
|
592
|
+
subscriptionId: step.id,
|
|
593
|
+
webhookUrl: ctx.receiver.url
|
|
594
|
+
});
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case `spawn`: throw new Error(`The old 'spawn' step is deprecated. Use 'spawnTyped' instead.`);
|
|
598
|
+
case `send`:
|
|
599
|
+
case `sendTo`: {
|
|
600
|
+
let entityUrl = ctx.currentEntityUrl;
|
|
601
|
+
if (step.kind === `sendTo`) entityUrl = step.url;
|
|
602
|
+
if (!entityUrl) throw new Error(`No current entity — did you spawn first?`);
|
|
603
|
+
const body = { payload: step.payload };
|
|
604
|
+
if (step.from) body.from = step.from;
|
|
605
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${entityUrl}/send`, {
|
|
606
|
+
method: `POST`,
|
|
607
|
+
body: JSON.stringify(body)
|
|
608
|
+
});
|
|
609
|
+
(0, vitest.expect)(res.status).toBe(204);
|
|
610
|
+
ctx.history.push({
|
|
611
|
+
type: `message_sent`,
|
|
612
|
+
entityUrl,
|
|
613
|
+
payload: step.payload,
|
|
614
|
+
from: step.from
|
|
615
|
+
});
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case `expectWebhook`: {
|
|
619
|
+
const timeoutMs = step.opts?.timeoutMs ?? 1e4;
|
|
620
|
+
const notification = await ctx.receiver.waitForNotification(timeoutMs);
|
|
621
|
+
(0, vitest.expect)(notification.parsed.consumer_id).toBeDefined();
|
|
622
|
+
(0, vitest.expect)(notification.parsed.epoch).toBeGreaterThan(0);
|
|
623
|
+
(0, vitest.expect)(notification.parsed.wake_id).toBeDefined();
|
|
624
|
+
ctx.notification = notification;
|
|
625
|
+
ctx.history.push({
|
|
626
|
+
type: `webhook_received`,
|
|
627
|
+
consumer_id: notification.parsed.consumer_id,
|
|
628
|
+
epoch: notification.parsed.epoch,
|
|
629
|
+
wake_id: notification.parsed.wake_id,
|
|
630
|
+
entity: notification.parsed.entity,
|
|
631
|
+
trigger_event: notification.parsed.trigger_event
|
|
632
|
+
});
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
case `respondDone`: {
|
|
636
|
+
if (!ctx.notification) throw new Error(`No notification to respond to`);
|
|
637
|
+
ctx.notification.resolve({
|
|
638
|
+
status: 200,
|
|
639
|
+
body: JSON.stringify({ done: true })
|
|
640
|
+
});
|
|
641
|
+
ctx.history.push({
|
|
642
|
+
type: `webhook_responded`,
|
|
643
|
+
status: 200,
|
|
644
|
+
body: { done: true }
|
|
645
|
+
});
|
|
646
|
+
ctx.notification = null;
|
|
647
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
case `expectEntityContext`: {
|
|
651
|
+
if (!ctx.notification) throw new Error(`No notification — did you expectWebhook first?`);
|
|
652
|
+
const entity = ctx.notification.parsed.entity;
|
|
653
|
+
(0, vitest.expect)(entity, `Webhook payload must contain entity context`).toBeDefined();
|
|
654
|
+
(0, vitest.expect)(entity.url).toBeTruthy();
|
|
655
|
+
(0, vitest.expect)(entity.streams.main).toBeTruthy();
|
|
656
|
+
(0, vitest.expect)(entity.streams.error).toBeTruthy();
|
|
657
|
+
if (step.checks?.url) (0, vitest.expect)(entity.url).toBe(step.checks.url);
|
|
658
|
+
if (step.checks?.type) (0, vitest.expect)(entity.type).toBe(step.checks.type);
|
|
659
|
+
if (step.checks?.status) (0, vitest.expect)(entity.status).toBe(step.checks.status);
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
case `expectStatus`: {
|
|
663
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
664
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
|
|
665
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
666
|
+
const entity = await res.json();
|
|
667
|
+
if (step.status === `running`) (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
|
|
668
|
+
else (0, vitest.expect)(entity.status).toBe(step.status);
|
|
669
|
+
ctx.history.push({
|
|
670
|
+
type: `entity_status_checked`,
|
|
671
|
+
entityUrl: ctx.currentEntityUrl,
|
|
672
|
+
status: entity.status
|
|
673
|
+
});
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case `kill`:
|
|
677
|
+
case `killUrl`: {
|
|
678
|
+
let entityUrl = ctx.currentEntityUrl;
|
|
679
|
+
if (step.kind === `killUrl`) entityUrl = step.url;
|
|
680
|
+
if (!entityUrl) throw new Error(`No current entity — did you spawn first?`);
|
|
681
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, entityUrl, { method: `DELETE` });
|
|
682
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
683
|
+
ctx.history.push({
|
|
684
|
+
type: `entity_killed`,
|
|
685
|
+
entityUrl
|
|
686
|
+
});
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case `readStream`: {
|
|
690
|
+
if (!ctx.currentEntityStreams) throw new Error(`No current entity streams`);
|
|
691
|
+
const streamPath = step.stream === `error` ? ctx.currentEntityStreams.error : ctx.currentEntityStreams.main;
|
|
692
|
+
const res = await fetch(`${ctx.baseUrl}${streamPath}?offset=0000000000000000_0000000000000000`);
|
|
693
|
+
if (res.status === 200) {
|
|
694
|
+
const text = await res.text();
|
|
695
|
+
const messages = text ? JSON.parse(text) : [];
|
|
696
|
+
ctx.lastStreamMessages = Array.isArray(messages) ? messages : [messages];
|
|
697
|
+
ctx.history.push({
|
|
698
|
+
type: `stream_read`,
|
|
699
|
+
path: streamPath,
|
|
700
|
+
messageCount: ctx.lastStreamMessages.length
|
|
701
|
+
});
|
|
702
|
+
} else {
|
|
703
|
+
ctx.lastStreamMessages = [];
|
|
704
|
+
ctx.history.push({
|
|
705
|
+
type: `stream_read`,
|
|
706
|
+
path: streamPath,
|
|
707
|
+
messageCount: 0
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case `expectStreamContains`: {
|
|
713
|
+
if (!ctx.lastStreamMessages) throw new Error(`No stream messages — did you readStream first?`);
|
|
714
|
+
const found = ctx.lastStreamMessages.some((m) => m.type === step.messageType);
|
|
715
|
+
(0, vitest.expect)(found, `Stream should contain message of type '${step.messageType}'`).toBe(true);
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case `list`: {
|
|
719
|
+
const entities = await fetchEntitiesViaShape(ctx.baseUrl, step.filter);
|
|
720
|
+
let result = entities;
|
|
721
|
+
if (step.filter?.offset !== void 0) result = result.slice(step.filter.offset);
|
|
722
|
+
if (step.filter?.limit !== void 0) result = result.slice(0, step.filter.limit);
|
|
723
|
+
ctx.lastListResult = result;
|
|
724
|
+
ctx.lastListTotal = entities.length;
|
|
725
|
+
ctx.history.push({
|
|
726
|
+
type: `entity_list_fetched`,
|
|
727
|
+
count: result.length,
|
|
728
|
+
filter: step.filter
|
|
729
|
+
});
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case `expectListCount`: {
|
|
733
|
+
if (!ctx.lastListResult) throw new Error(`No list result — did you list() first?`);
|
|
734
|
+
if (step.exact !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBe(step.exact);
|
|
735
|
+
if (step.min !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBeGreaterThanOrEqual(step.min);
|
|
736
|
+
if (step.max !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBeLessThanOrEqual(step.max);
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
case `expectSpawnError`: {
|
|
740
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, step.url, {
|
|
741
|
+
method: `PUT`,
|
|
742
|
+
body: JSON.stringify({})
|
|
743
|
+
});
|
|
744
|
+
(0, vitest.expect)(res.status).toBe(step.status);
|
|
745
|
+
const body = await res.json();
|
|
746
|
+
(0, vitest.expect)(body.error.code).toBe(step.code);
|
|
747
|
+
ctx.history.push({
|
|
748
|
+
type: `spawn_rejected`,
|
|
749
|
+
url: step.url,
|
|
750
|
+
status: step.status,
|
|
751
|
+
code: step.code
|
|
752
|
+
});
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
case `expectSendError`: {
|
|
756
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
757
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
|
|
758
|
+
method: `POST`,
|
|
759
|
+
body: JSON.stringify({
|
|
760
|
+
from: `test`,
|
|
761
|
+
payload: { should: `fail` }
|
|
762
|
+
})
|
|
763
|
+
});
|
|
764
|
+
(0, vitest.expect)(res.status).toBe(step.status);
|
|
765
|
+
const body = await res.json();
|
|
766
|
+
(0, vitest.expect)(body.error.code).toBe(step.code);
|
|
767
|
+
ctx.history.push({
|
|
768
|
+
type: `send_rejected`,
|
|
769
|
+
entityUrl: ctx.currentEntityUrl,
|
|
770
|
+
status: step.status,
|
|
771
|
+
code: step.code
|
|
772
|
+
});
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
case `expectListTotal`: {
|
|
776
|
+
if (ctx.lastListTotal === null) throw new Error(`No list total — did you list() first?`);
|
|
777
|
+
(0, vitest.expect)(ctx.lastListTotal).toBe(step.total);
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
case `wait`: {
|
|
781
|
+
await new Promise((r) => setTimeout(r, step.ms));
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
case `custom`: {
|
|
785
|
+
await step.fn(ctx);
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case `registerType`: {
|
|
789
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types`, {
|
|
790
|
+
method: `POST`,
|
|
791
|
+
body: JSON.stringify(toServerEntityTypeRegistration(step.registration))
|
|
792
|
+
});
|
|
793
|
+
(0, vitest.expect)(res.status).toBe(201);
|
|
794
|
+
const body = await res.json();
|
|
795
|
+
ctx.currentEntityType = step.registration.name;
|
|
796
|
+
ctx.history.push({
|
|
797
|
+
type: `entity_type_registered`,
|
|
798
|
+
name: step.registration.name,
|
|
799
|
+
revision: body.revision
|
|
800
|
+
});
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
case `expectTypeExists`: {
|
|
804
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
|
|
805
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
case `inspectType`: {
|
|
809
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
|
|
810
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
811
|
+
const body = await res.json();
|
|
812
|
+
ctx.lastTypeResult = body;
|
|
813
|
+
ctx.history.push({
|
|
814
|
+
type: `entity_type_inspected`,
|
|
815
|
+
name: step.name,
|
|
816
|
+
revision: body.revision
|
|
817
|
+
});
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
case `deleteType`: {
|
|
821
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`, { method: `DELETE` });
|
|
822
|
+
(0, vitest.expect)([200, 204]).toContain(res.status);
|
|
823
|
+
ctx.history.push({
|
|
824
|
+
type: `entity_type_deleted`,
|
|
825
|
+
name: step.name
|
|
826
|
+
});
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
case `expectTypeNotExists`: {
|
|
830
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
|
|
831
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case `amendSchemas`: {
|
|
835
|
+
const body = {};
|
|
836
|
+
if (step.input_schemas) body.inbox_schemas = step.input_schemas;
|
|
837
|
+
if (step.output_schemas) body.state_schemas = step.output_schemas;
|
|
838
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}/schemas`, {
|
|
839
|
+
method: `PATCH`,
|
|
840
|
+
body: JSON.stringify(body)
|
|
841
|
+
});
|
|
842
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
843
|
+
const result = await res.json();
|
|
844
|
+
ctx.history.push({
|
|
845
|
+
type: `entity_type_schemas_amended`,
|
|
846
|
+
name: step.name,
|
|
847
|
+
revision: result.revision
|
|
848
|
+
});
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
case `listTypes`: {
|
|
852
|
+
ctx.lastTypeListResult = await fetchShapeRows(ctx.baseUrl, `entity_types`);
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
case `registerTypeViaServe`: {
|
|
856
|
+
const receiver = new ServeEndpointReceiver();
|
|
857
|
+
await receiver.start(step.registration);
|
|
858
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types`, {
|
|
859
|
+
method: `POST`,
|
|
860
|
+
body: JSON.stringify({
|
|
861
|
+
name: step.registration.name,
|
|
862
|
+
serve_endpoint: receiver.url
|
|
863
|
+
})
|
|
864
|
+
});
|
|
865
|
+
(0, vitest.expect)(res.status).toBe(201);
|
|
866
|
+
const body = await res.json();
|
|
867
|
+
ctx.currentEntityType = step.registration.name;
|
|
868
|
+
ctx.serveReceiver = receiver;
|
|
869
|
+
ctx.history.push({
|
|
870
|
+
type: `entity_type_registered`,
|
|
871
|
+
name: step.registration.name,
|
|
872
|
+
revision: body.revision
|
|
873
|
+
});
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case `spawnTyped`: {
|
|
877
|
+
const body = {};
|
|
878
|
+
if (step.args) body.args = step.args;
|
|
879
|
+
if (step.tags) body.tags = step.tags;
|
|
880
|
+
if (step.parent) body.parent = step.parent;
|
|
881
|
+
if (step.initialMessage !== void 0) body.initialMessage = step.initialMessage;
|
|
882
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/${step.typeName}/${step.instanceId}`, {
|
|
883
|
+
method: `PUT`,
|
|
884
|
+
body: JSON.stringify(body)
|
|
885
|
+
});
|
|
886
|
+
(0, vitest.expect)(res.status).toBe(201);
|
|
887
|
+
const entity = await res.json();
|
|
888
|
+
ctx.currentEntityUrl = entity.url;
|
|
889
|
+
ctx.currentEntityStreams = entity.streams;
|
|
890
|
+
ctx.currentWriteToken = null;
|
|
891
|
+
ctx.history.push({
|
|
892
|
+
type: `entity_spawned`,
|
|
893
|
+
entityUrl: entity.url,
|
|
894
|
+
entityType: step.typeName,
|
|
895
|
+
status: entity.status,
|
|
896
|
+
streams: entity.streams,
|
|
897
|
+
parent: step.parent
|
|
898
|
+
});
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
case `write`: {
|
|
902
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
903
|
+
const body = {
|
|
904
|
+
type: step.eventType ?? `default`,
|
|
905
|
+
key: `write-${ctx.history.length}`,
|
|
906
|
+
value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
|
|
907
|
+
headers: { operation: `insert` }
|
|
908
|
+
};
|
|
909
|
+
const writeHeaders = {};
|
|
910
|
+
if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
911
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
|
|
912
|
+
method: `POST`,
|
|
913
|
+
headers: writeHeaders,
|
|
914
|
+
body: JSON.stringify(body)
|
|
915
|
+
});
|
|
916
|
+
(0, vitest.expect)([200, 204]).toContain(res.status);
|
|
917
|
+
ctx.history.push({
|
|
918
|
+
type: `entity_write`,
|
|
919
|
+
entityUrl: ctx.currentEntityUrl,
|
|
920
|
+
eventType: step.eventType,
|
|
921
|
+
payload: step.payload
|
|
922
|
+
});
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
case `writeStateProtocol`: {
|
|
926
|
+
const entityUrl = ctx.currentEntityUrl;
|
|
927
|
+
if (!entityUrl) throw new Error(`No current entity for writeStateProtocol`);
|
|
928
|
+
const writeHeaders = {};
|
|
929
|
+
if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
930
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${entityUrl}/main`, {
|
|
931
|
+
method: `POST`,
|
|
932
|
+
headers: writeHeaders,
|
|
933
|
+
body: JSON.stringify(step.event)
|
|
934
|
+
});
|
|
935
|
+
(0, vitest.expect)([200, 204]).toContain(res.status);
|
|
936
|
+
ctx.history.push({
|
|
937
|
+
type: `entity_write`,
|
|
938
|
+
entityUrl,
|
|
939
|
+
eventType: step.event.type,
|
|
940
|
+
payload: step.event
|
|
941
|
+
});
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case `expectStreamEvent`: {
|
|
945
|
+
if (!ctx.lastStreamMessages) throw new Error(`readStream() must be called first`);
|
|
946
|
+
const match = ctx.lastStreamMessages.find((e) => e.type === step.type && e.key === step.key && e.headers?.operation === step.operation);
|
|
947
|
+
(0, vitest.expect)(match).toBeDefined();
|
|
948
|
+
if (step.valueCheck && match) step.valueCheck(match.value ?? {});
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case `expectStreamEventCount`: {
|
|
952
|
+
if (!ctx.lastStreamMessages) throw new Error(`readStream() must be called first`);
|
|
953
|
+
const count = ctx.lastStreamMessages.filter((e) => e.type === step.type).length;
|
|
954
|
+
(0, vitest.expect)(count).toBe(step.count);
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
case `setTags`: {
|
|
958
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
959
|
+
const tagHeaders = {};
|
|
960
|
+
if (ctx.currentWriteToken) tagHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
961
|
+
for (const [key, value] of Object.entries(step.tags)) {
|
|
962
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/${encodeURIComponent(key)}`, {
|
|
963
|
+
method: `POST`,
|
|
964
|
+
headers: tagHeaders,
|
|
965
|
+
body: JSON.stringify({ value })
|
|
966
|
+
});
|
|
967
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
968
|
+
}
|
|
969
|
+
ctx.history.push({
|
|
970
|
+
type: `tags_updated`,
|
|
971
|
+
entityUrl: ctx.currentEntityUrl,
|
|
972
|
+
tags: step.tags
|
|
973
|
+
});
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
case `expectTags`: {
|
|
977
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
978
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
|
|
979
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
980
|
+
const entity = await res.json();
|
|
981
|
+
(0, vitest.expect)(entity.tags).toEqual(step.tags);
|
|
982
|
+
ctx.history.push({
|
|
983
|
+
type: `tags_checked`,
|
|
984
|
+
entityUrl: ctx.currentEntityUrl,
|
|
985
|
+
tags: step.tags
|
|
986
|
+
});
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
989
|
+
case `expectSpawnSchemaError`: {
|
|
990
|
+
const body = {};
|
|
991
|
+
if (step.args) body.args = step.args;
|
|
992
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `/${step.typeName}/${step.instanceId}`, {
|
|
993
|
+
method: `PUT`,
|
|
994
|
+
body: JSON.stringify(body)
|
|
995
|
+
});
|
|
996
|
+
(0, vitest.expect)(res.status).toBe(422);
|
|
997
|
+
const result = await res.json();
|
|
998
|
+
(0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
|
|
999
|
+
ctx.history.push({
|
|
1000
|
+
type: `spawn_schema_rejected`,
|
|
1001
|
+
typeName: step.typeName,
|
|
1002
|
+
instanceId: step.instanceId,
|
|
1003
|
+
status: 422,
|
|
1004
|
+
code: `SCHEMA_VALIDATION_FAILED`
|
|
1005
|
+
});
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
case `expectSendSchemaError`: {
|
|
1009
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
1010
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
|
|
1011
|
+
method: `POST`,
|
|
1012
|
+
body: JSON.stringify({
|
|
1013
|
+
type: step.messageType,
|
|
1014
|
+
payload: step.payload
|
|
1015
|
+
})
|
|
1016
|
+
});
|
|
1017
|
+
(0, vitest.expect)(res.status).toBe(422);
|
|
1018
|
+
const result = await res.json();
|
|
1019
|
+
(0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
|
|
1020
|
+
ctx.history.push({
|
|
1021
|
+
type: `send_schema_rejected`,
|
|
1022
|
+
entityUrl: ctx.currentEntityUrl,
|
|
1023
|
+
messageType: step.messageType,
|
|
1024
|
+
status: 422,
|
|
1025
|
+
code: `SCHEMA_VALIDATION_FAILED`
|
|
1026
|
+
});
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
case `expectWriteSchemaError`: {
|
|
1030
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
1031
|
+
const writeHeaders = {};
|
|
1032
|
+
if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
1033
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
|
|
1034
|
+
method: `POST`,
|
|
1035
|
+
headers: writeHeaders,
|
|
1036
|
+
body: JSON.stringify({
|
|
1037
|
+
type: step.eventType,
|
|
1038
|
+
key: `schema-test-${ctx.history.length}`,
|
|
1039
|
+
value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
|
|
1040
|
+
headers: { operation: `insert` }
|
|
1041
|
+
})
|
|
1042
|
+
});
|
|
1043
|
+
(0, vitest.expect)(res.status).toBe(422);
|
|
1044
|
+
const result = await res.json();
|
|
1045
|
+
(0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
|
|
1046
|
+
ctx.history.push({
|
|
1047
|
+
type: `write_schema_rejected`,
|
|
1048
|
+
entityUrl: ctx.currentEntityUrl,
|
|
1049
|
+
eventType: step.eventType,
|
|
1050
|
+
status: 422,
|
|
1051
|
+
code: `SCHEMA_VALIDATION_FAILED`
|
|
1052
|
+
});
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
case `expectSendUnknownType`: {
|
|
1056
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
1057
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
|
|
1058
|
+
method: `POST`,
|
|
1059
|
+
body: JSON.stringify({
|
|
1060
|
+
type: step.messageType,
|
|
1061
|
+
payload: step.payload
|
|
1062
|
+
})
|
|
1063
|
+
});
|
|
1064
|
+
(0, vitest.expect)(res.status).toBe(422);
|
|
1065
|
+
const result = await res.json();
|
|
1066
|
+
(0, vitest.expect)(result.error.code).toBe(`UNKNOWN_MESSAGE_TYPE`);
|
|
1067
|
+
ctx.history.push({
|
|
1068
|
+
type: `send_unknown_type_rejected`,
|
|
1069
|
+
entityUrl: ctx.currentEntityUrl,
|
|
1070
|
+
messageType: step.messageType,
|
|
1071
|
+
status: 422,
|
|
1072
|
+
code: `UNKNOWN_MESSAGE_TYPE`
|
|
1073
|
+
});
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
case `expectWriteUnknownType`: {
|
|
1077
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
1078
|
+
const writeHeaders = {};
|
|
1079
|
+
if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
1080
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
|
|
1081
|
+
method: `POST`,
|
|
1082
|
+
headers: writeHeaders,
|
|
1083
|
+
body: JSON.stringify({
|
|
1084
|
+
type: step.eventType,
|
|
1085
|
+
key: `unknown-type-test-${ctx.history.length}`,
|
|
1086
|
+
value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
|
|
1087
|
+
headers: { operation: `insert` }
|
|
1088
|
+
})
|
|
1089
|
+
});
|
|
1090
|
+
(0, vitest.expect)(res.status).toBe(422);
|
|
1091
|
+
const result = await res.json();
|
|
1092
|
+
(0, vitest.expect)(result.error.code).toBe(`UNKNOWN_EVENT_TYPE`);
|
|
1093
|
+
ctx.history.push({
|
|
1094
|
+
type: `write_unknown_type_rejected`,
|
|
1095
|
+
entityUrl: ctx.currentEntityUrl,
|
|
1096
|
+
eventType: step.eventType,
|
|
1097
|
+
status: 422,
|
|
1098
|
+
code: `UNKNOWN_EVENT_TYPE`
|
|
1099
|
+
});
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
case `expectEntityPersisted`: {
|
|
1103
|
+
if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
|
|
1104
|
+
const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
|
|
1105
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
1106
|
+
ctx.history.push({
|
|
1107
|
+
type: `entity_persisted_verified`,
|
|
1108
|
+
entityUrl: ctx.currentEntityUrl
|
|
1109
|
+
});
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function checkInvariants(history) {
|
|
1115
|
+
checkSpawnPrecedesSend(history);
|
|
1116
|
+
checkSpawnPrecedesKill(history);
|
|
1117
|
+
checkNoSendAfterKill(history);
|
|
1118
|
+
checkEntityContextOnWebhook(history);
|
|
1119
|
+
checkStreamPathsMatchEntityUrl(history);
|
|
1120
|
+
checkStatusTransitionsValid(history);
|
|
1121
|
+
checkEntityTypeExistsBeforeSpawn(history);
|
|
1122
|
+
checkSchemaValidationAtGates(history);
|
|
1123
|
+
checkAdditiveSchemaEvolution(history);
|
|
1124
|
+
checkRegistryStreamConsistency(history);
|
|
1125
|
+
checkParentExists(history);
|
|
1126
|
+
checkNoSpawnAfterTypeDeleted(history);
|
|
1127
|
+
const spWrites = history.filter((e) => e.type === `state_protocol_write`);
|
|
1128
|
+
for (const w of spWrites) {
|
|
1129
|
+
const spw = w;
|
|
1130
|
+
(0, vitest.expect)([
|
|
1131
|
+
`run`,
|
|
1132
|
+
`step`,
|
|
1133
|
+
`text`,
|
|
1134
|
+
`tool_call`,
|
|
1135
|
+
`text_delta`
|
|
1136
|
+
].includes(spw.eventType), `State Protocol write must have valid event type, got: ${spw.eventType}`).toBe(true);
|
|
1137
|
+
(0, vitest.expect)(spw.key, `State Protocol write must have a key`).toBeTruthy();
|
|
1138
|
+
(0, vitest.expect)(spw.entityUrl, `State Protocol write must reference an entity`).toBeTruthy();
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Spec S8 — Precedence: an entity must be spawned before messages can be sent to it.
|
|
1143
|
+
* ¬send W spawn — "spawn must happen before send"
|
|
1144
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1145
|
+
*/
|
|
1146
|
+
function checkSpawnPrecedesSend(history) {
|
|
1147
|
+
const spawned = new Set();
|
|
1148
|
+
for (const event of history) {
|
|
1149
|
+
if (event.type === `entity_spawned`) spawned.add(event.entityUrl);
|
|
1150
|
+
if (event.type === `message_sent`) (0, vitest.expect)(spawned.has(event.entityUrl), `Precedence: message_sent to ${event.entityUrl} but entity was not spawned first`).toBe(true);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Spec S9 — Precedence: an entity must be spawned before it can be killed.
|
|
1155
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1156
|
+
*/
|
|
1157
|
+
function checkSpawnPrecedesKill(history) {
|
|
1158
|
+
const spawned = new Set();
|
|
1159
|
+
for (const event of history) {
|
|
1160
|
+
if (event.type === `entity_spawned`) spawned.add(event.entityUrl);
|
|
1161
|
+
if (event.type === `entity_killed`) (0, vitest.expect)(spawned.has(event.entityUrl), `Precedence: entity_killed ${event.entityUrl} but entity was not spawned first`).toBe(true);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Spec S3 — Safety: no successful send after kill.
|
|
1166
|
+
* Once an entity is killed, message_sent should not appear for it.
|
|
1167
|
+
* (send_rejected is fine — that's the expected error path.)
|
|
1168
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1169
|
+
*/
|
|
1170
|
+
function checkNoSendAfterKill(history) {
|
|
1171
|
+
const killed = new Set();
|
|
1172
|
+
for (const event of history) {
|
|
1173
|
+
if (event.type === `entity_killed`) killed.add(event.entityUrl);
|
|
1174
|
+
if (event.type === `message_sent`) (0, vitest.expect)(!killed.has(event.entityUrl), `Safety: message_sent to ${event.entityUrl} after it was killed`).toBe(true);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Spec S6 — Safety: every webhook received while Electric Agents is enabled must contain
|
|
1179
|
+
* entity context (entity field in the payload).
|
|
1180
|
+
* Soundness: Sound | Completeness: Incomplete (only checks observed webhooks)
|
|
1181
|
+
*/
|
|
1182
|
+
function checkEntityContextOnWebhook(history) {
|
|
1183
|
+
for (const event of history) if (event.type === `webhook_received`) {
|
|
1184
|
+
(0, vitest.expect)(event.entity, `Safety: webhook_received must contain entity context`).toBeDefined();
|
|
1185
|
+
(0, vitest.expect)(event.entity.url).toBeTruthy();
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Spec S5 — Structural: stream paths must match {entity.url}/main and {entity.url}/error.
|
|
1190
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1191
|
+
*/
|
|
1192
|
+
function checkStreamPathsMatchEntityUrl(history) {
|
|
1193
|
+
for (const event of history) if (event.type === `entity_spawned`) {
|
|
1194
|
+
(0, vitest.expect)(event.streams.main).toBe(`${event.entityUrl}/main`);
|
|
1195
|
+
(0, vitest.expect)(event.streams.error).toBe(`${event.entityUrl}/error`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Spec S4 — Safety: entity status transitions must be valid.
|
|
1200
|
+
* spawning → running is valid (at spawn time)
|
|
1201
|
+
* running/idle → stopped is valid (at kill time)
|
|
1202
|
+
* stopped → running is NOT valid
|
|
1203
|
+
* Soundness: Sound | Completeness: Incomplete (only checks observed status reads)
|
|
1204
|
+
*/
|
|
1205
|
+
function checkStatusTransitionsValid(history) {
|
|
1206
|
+
const lastStatus = new Map();
|
|
1207
|
+
for (const event of history) {
|
|
1208
|
+
if (event.type === `entity_spawned`) lastStatus.set(event.entityUrl, event.status);
|
|
1209
|
+
if (event.type === `entity_status_checked`) {
|
|
1210
|
+
const prev = lastStatus.get(event.entityUrl);
|
|
1211
|
+
if (prev === `stopped`) (0, vitest.expect)(event.status, `Safety: entity ${event.entityUrl} transitioned from stopped to ${event.status}`).toBe(`stopped`);
|
|
1212
|
+
lastStatus.set(event.entityUrl, event.status);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Spec R1 — Structural: every entity_spawned must be preceded by an
|
|
1218
|
+
* entity_type_registered event for the matching entity type.
|
|
1219
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1220
|
+
*/
|
|
1221
|
+
function checkEntityTypeExistsBeforeSpawn(history) {
|
|
1222
|
+
const registeredTypes = new Set();
|
|
1223
|
+
for (const event of history) {
|
|
1224
|
+
if (event.type === `entity_type_registered`) registeredTypes.add(event.name);
|
|
1225
|
+
if (event.type === `entity_spawned` && event.entityType !== void 0) (0, vitest.expect)(registeredTypes.has(event.entityType), `Structural: entity_spawned for type '${event.entityType}' but no prior entity_type_registered event found`).toBe(true);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Spec R2 — Safety: schema rejection events must carry the correct error code.
|
|
1230
|
+
* All schema rejection events (spawn/send/write) must have code SCHEMA_VALIDATION_FAILED.
|
|
1231
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1232
|
+
*/
|
|
1233
|
+
function checkSchemaValidationAtGates(history) {
|
|
1234
|
+
for (const event of history) if (event.type === `spawn_schema_rejected` || event.type === `send_schema_rejected` || event.type === `write_schema_rejected`) (0, vitest.expect)(event.code, `Safety: ${event.type} must have code SCHEMA_VALIDATION_FAILED, got '${event.code}'`).toBe(`SCHEMA_VALIDATION_FAILED`);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Spec R3 — Additive-only schema evolution: entity_type_schemas_amended events
|
|
1238
|
+
* must not remove or rename schema keys that existed in the prior registration.
|
|
1239
|
+
* This checker works on the step inputs stored in history by tracking which
|
|
1240
|
+
* amendment steps appear in sequence. Since the history events for amendments
|
|
1241
|
+
* only record name and revision (not the actual schema keys), this invariant
|
|
1242
|
+
* verifies structural ordering — each amendment event must follow a registration
|
|
1243
|
+
* event. Full key-level checking would require schema contents in the event.
|
|
1244
|
+
* Soundness: Partial | Completeness: Incomplete (key contents not in history events)
|
|
1245
|
+
*
|
|
1246
|
+
* Note: The invariant verifies that every schema amendment is preceded by a
|
|
1247
|
+
* registration of the same type name, consistent with additive-only evolution.
|
|
1248
|
+
*/
|
|
1249
|
+
function checkAdditiveSchemaEvolution(history) {
|
|
1250
|
+
const registeredTypes = new Set();
|
|
1251
|
+
for (const event of history) {
|
|
1252
|
+
if (event.type === `entity_type_registered`) registeredTypes.add(event.name);
|
|
1253
|
+
if (event.type === `entity_type_schemas_amended`) (0, vitest.expect)(registeredTypes.has(event.name), `Additive evolution: entity_type_schemas_amended for '${event.name}' but no prior entity_type_registered event found`).toBe(true);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Spec R4 — Registry stream consistency: every entity_spawned must refer to an
|
|
1258
|
+
* entity that has not already been killed, and stream URLs must follow the
|
|
1259
|
+
* convention {entity.url}/main and {entity.url}/error.
|
|
1260
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1261
|
+
*/
|
|
1262
|
+
function checkRegistryStreamConsistency(history) {
|
|
1263
|
+
const killedEntities = new Set();
|
|
1264
|
+
for (const event of history) {
|
|
1265
|
+
if (event.type === `entity_killed`) killedEntities.add(event.entityUrl);
|
|
1266
|
+
if (event.type === `entity_spawned`) {
|
|
1267
|
+
(0, vitest.expect)(!killedEntities.has(event.entityUrl), `Registry consistency: entity_spawned for ${event.entityUrl} but entity was already killed`).toBe(true);
|
|
1268
|
+
(0, vitest.expect)(event.streams.main, `Registry consistency: entity ${event.entityUrl} streams.main must be ${event.entityUrl}/main`).toBe(`${event.entityUrl}/main`);
|
|
1269
|
+
(0, vitest.expect)(event.streams.error, `Registry consistency: entity ${event.entityUrl} streams.error must be ${event.entityUrl}/error`).toBe(`${event.entityUrl}/error`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Spec R5 — Parent existence: if a spawned entity declares a parent, the
|
|
1275
|
+
* parent must have been spawned earlier in the trace and not yet killed.
|
|
1276
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1277
|
+
*/
|
|
1278
|
+
function checkParentExists(history) {
|
|
1279
|
+
const spawnedAlive = new Set();
|
|
1280
|
+
for (const event of history) {
|
|
1281
|
+
if (event.type === `entity_spawned`) {
|
|
1282
|
+
if (event.parent) (0, vitest.expect)(spawnedAlive.has(event.parent), `Parent ${event.parent} must be spawned and alive before child ${event.entityUrl} is spawned`).toBe(true);
|
|
1283
|
+
spawnedAlive.add(event.entityUrl);
|
|
1284
|
+
}
|
|
1285
|
+
if (event.type === `entity_killed`) spawnedAlive.delete(event.entityUrl);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Spec R6 — No spawn after type deleted: every entity_spawned event must not
|
|
1290
|
+
* be preceded by an entity_type_deleted event for the same type name (unless
|
|
1291
|
+
* re-registered between the deletion and the spawn).
|
|
1292
|
+
* Soundness: Sound | Completeness: Complete (within trace)
|
|
1293
|
+
*/
|
|
1294
|
+
function checkNoSpawnAfterTypeDeleted(history) {
|
|
1295
|
+
const deletedTypes = new Set();
|
|
1296
|
+
for (const event of history) {
|
|
1297
|
+
if (event.type === `entity_type_deleted`) deletedTypes.add(event.name);
|
|
1298
|
+
if (event.type === `entity_type_registered`) deletedTypes.delete(event.name);
|
|
1299
|
+
if (event.type === `entity_spawned` && event.entityType !== void 0) (0, vitest.expect)(!deletedTypes.has(event.entityType), `Safety: entity_spawned for type '${event.entityType}' after it was deleted without re-registration`).toBe(true);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* ENABLED predicate — determines which actions can fire from the current state.
|
|
1304
|
+
*
|
|
1305
|
+
* - register_type: always (up to a cap of 3 types)
|
|
1306
|
+
* - delete_type: when entity types exist and no running entities use them
|
|
1307
|
+
* - spawn: when at least one entity type is registered (up to a cap)
|
|
1308
|
+
* - send: when at least one entity is running
|
|
1309
|
+
* - kill: when at least one entity is running
|
|
1310
|
+
* - check_status: when at least one entity exists
|
|
1311
|
+
* - list: always
|
|
1312
|
+
*/
|
|
1313
|
+
function enabledElectricAgentsActions(model) {
|
|
1314
|
+
const enabled = [`list`];
|
|
1315
|
+
if (model.entityTypes.length < 3) enabled.push(`register_type`);
|
|
1316
|
+
const runningTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
|
|
1317
|
+
const deletableTypes = model.entityTypes.filter((t) => !runningTypeNames.has(t.name));
|
|
1318
|
+
if (deletableTypes.length > 0) enabled.push(`delete_type`);
|
|
1319
|
+
if (model.entityTypes.length > 0 && model.entities.length < 4) enabled.push(`spawn`);
|
|
1320
|
+
const hasRunning = model.entities.some((e) => e.status === `running`);
|
|
1321
|
+
if (hasRunning) enabled.push(`send`, `kill`, `check_status`);
|
|
1322
|
+
const hasAny = model.entities.length > 0;
|
|
1323
|
+
if (hasAny && !hasRunning) enabled.push(`check_status`);
|
|
1324
|
+
return enabled;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Next relation — pure state transition for the model.
|
|
1328
|
+
* The real server execution happens separately in the property test.
|
|
1329
|
+
*/
|
|
1330
|
+
function applyElectricAgentsAction(model, action, targetIdx) {
|
|
1331
|
+
switch (action) {
|
|
1332
|
+
case `register_type`: {
|
|
1333
|
+
const typeNum = model.entityTypes.length;
|
|
1334
|
+
return {
|
|
1335
|
+
...model,
|
|
1336
|
+
entityTypes: [...model.entityTypes, {
|
|
1337
|
+
name: `prop-type-${typeNum}`,
|
|
1338
|
+
hasCreationSchema: false,
|
|
1339
|
+
hasInputSchemas: false,
|
|
1340
|
+
hasOutputSchemas: false
|
|
1341
|
+
}]
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
case `delete_type`: {
|
|
1345
|
+
const runningTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
|
|
1346
|
+
const deletable = model.entityTypes.filter((t) => !runningTypeNames.has(t.name));
|
|
1347
|
+
if (deletable.length === 0) return model;
|
|
1348
|
+
const toDelete = deletable[0];
|
|
1349
|
+
return {
|
|
1350
|
+
...model,
|
|
1351
|
+
entityTypes: model.entityTypes.filter((t) => t.name !== toDelete.name)
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
case `spawn`: {
|
|
1355
|
+
if (model.entityTypes.length === 0) return model;
|
|
1356
|
+
const typeName = model.entityTypes[0].name;
|
|
1357
|
+
return {
|
|
1358
|
+
...model,
|
|
1359
|
+
entities: [...model.entities, {
|
|
1360
|
+
url: `/prop-placeholder/entity-${model.nextEntityNum}`,
|
|
1361
|
+
typeName,
|
|
1362
|
+
status: `running`,
|
|
1363
|
+
messageCount: 0
|
|
1364
|
+
}],
|
|
1365
|
+
nextEntityNum: model.nextEntityNum + 1
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
case `send`: {
|
|
1369
|
+
if (targetIdx === void 0) return model;
|
|
1370
|
+
const entities = [...model.entities];
|
|
1371
|
+
const e = entities[targetIdx];
|
|
1372
|
+
entities[targetIdx] = {
|
|
1373
|
+
...e,
|
|
1374
|
+
messageCount: e.messageCount + 1
|
|
1375
|
+
};
|
|
1376
|
+
return {
|
|
1377
|
+
...model,
|
|
1378
|
+
entities
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
case `kill`: {
|
|
1382
|
+
if (targetIdx === void 0) return model;
|
|
1383
|
+
const entities = [...model.entities];
|
|
1384
|
+
const e = entities[targetIdx];
|
|
1385
|
+
entities[targetIdx] = {
|
|
1386
|
+
...e,
|
|
1387
|
+
status: `stopped`
|
|
1388
|
+
};
|
|
1389
|
+
return {
|
|
1390
|
+
...model,
|
|
1391
|
+
entities
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
case `check_status`:
|
|
1395
|
+
case `list`: return model;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
function electricAgents(baseUrl) {
|
|
1399
|
+
return new ElectricAgentsScenario(baseUrl);
|
|
1400
|
+
}
|
|
1401
|
+
function checkStateProtocolInvariants(events) {
|
|
1402
|
+
const stateProtocolEvents = events.filter((e) => e.type && e.key && e.headers);
|
|
1403
|
+
if (stateProtocolEvents.length === 0) return;
|
|
1404
|
+
const runs = new Map();
|
|
1405
|
+
for (const e of stateProtocolEvents) {
|
|
1406
|
+
if (e.type !== `run`) continue;
|
|
1407
|
+
const key = e.key;
|
|
1408
|
+
if (!runs.has(key)) runs.set(key, []);
|
|
1409
|
+
runs.get(key).push(e);
|
|
1410
|
+
}
|
|
1411
|
+
for (const [key, evts] of runs) (0, vitest.expect)(evts[0].headers.operation, `E1: run ${key} must start with insert`).toBe(`insert`);
|
|
1412
|
+
const steps = new Map();
|
|
1413
|
+
for (const e of stateProtocolEvents) {
|
|
1414
|
+
if (e.type !== `step`) continue;
|
|
1415
|
+
const key = e.key;
|
|
1416
|
+
if (!steps.has(key)) steps.set(key, []);
|
|
1417
|
+
steps.get(key).push(e);
|
|
1418
|
+
}
|
|
1419
|
+
for (const [key, evts] of steps) (0, vitest.expect)(evts[0].headers.operation, `E2: step ${key} must start with insert`).toBe(`insert`);
|
|
1420
|
+
const texts = new Map();
|
|
1421
|
+
for (const e of stateProtocolEvents) {
|
|
1422
|
+
if (e.type !== `text`) continue;
|
|
1423
|
+
const key = e.key;
|
|
1424
|
+
if (!texts.has(key)) texts.set(key, []);
|
|
1425
|
+
texts.get(key).push(e);
|
|
1426
|
+
}
|
|
1427
|
+
for (const [key, evts] of texts) (0, vitest.expect)(evts[0].headers.operation, `E3: text ${key} must start with insert`).toBe(`insert`);
|
|
1428
|
+
const toolCalls = new Map();
|
|
1429
|
+
for (const e of stateProtocolEvents) {
|
|
1430
|
+
if (e.type !== `tool_call`) continue;
|
|
1431
|
+
const key = e.key;
|
|
1432
|
+
if (!toolCalls.has(key)) toolCalls.set(key, []);
|
|
1433
|
+
toolCalls.get(key).push(e);
|
|
1434
|
+
}
|
|
1435
|
+
for (const [key, evts] of toolCalls) {
|
|
1436
|
+
(0, vitest.expect)(evts.length).toBeGreaterThanOrEqual(3);
|
|
1437
|
+
(0, vitest.expect)(evts[0].headers.operation, `E4: tool_call ${key} must start with insert`).toBe(`insert`);
|
|
1438
|
+
(0, vitest.expect)(evts[0].value?.status, `E4: tool_call ${key} insert must have status started`).toBe(`started`);
|
|
1439
|
+
const statuses = evts.map((e) => e.value?.status);
|
|
1440
|
+
const executingIdx = statuses.indexOf(`executing`);
|
|
1441
|
+
(0, vitest.expect)(executingIdx, `E4: tool_call ${key} must have executing state`).toBeGreaterThan(0);
|
|
1442
|
+
if (executingIdx > 1) (0, vitest.expect)(statuses[1], `E4: tool_call ${key} event after started must be args_complete if not executing`).toBe(`args_complete`);
|
|
1443
|
+
const lastStatus = statuses[statuses.length - 1];
|
|
1444
|
+
(0, vitest.expect)(lastStatus === `completed` || lastStatus === `failed`, `E4: tool_call ${key} must end with completed or failed, got ${lastStatus}`).toBe(true);
|
|
1445
|
+
const toolNames = evts.map((e) => e.value?.tool_name).filter(Boolean);
|
|
1446
|
+
if (toolNames.length > 0) {
|
|
1447
|
+
const first = toolNames[0];
|
|
1448
|
+
for (const tn of toolNames) (0, vitest.expect)(tn, `E4: tool_name must be consistent for ${key}`).toBe(first);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const textDeltas = stateProtocolEvents.filter((e) => e.type === `text_delta`);
|
|
1452
|
+
for (const d of textDeltas) {
|
|
1453
|
+
const key = d.key;
|
|
1454
|
+
(0, vitest.expect)(key, `E5: text_delta key must contain ':'`).toContain(`:`);
|
|
1455
|
+
const [parentKey, seqStr] = key.split(`:`);
|
|
1456
|
+
(0, vitest.expect)(Number.isInteger(parseInt(seqStr, 10)), `E5: text_delta seq must be integer`).toBe(true);
|
|
1457
|
+
const val = d.value;
|
|
1458
|
+
if (val) (0, vitest.expect)(val.text_id, `E5: text_delta value.text_id must match parent key`).toBe(parentKey);
|
|
1459
|
+
}
|
|
1460
|
+
for (const [key, evts] of steps) {
|
|
1461
|
+
for (const e of evts) {
|
|
1462
|
+
const val = e.value;
|
|
1463
|
+
(0, vitest.expect)(val?.step_number, `E6: step ${key} must have step_number`).toBeDefined();
|
|
1464
|
+
}
|
|
1465
|
+
const stepNumbers = evts.map((e) => e.value?.step_number).filter((n) => n !== void 0);
|
|
1466
|
+
if (stepNumbers.length > 1) {
|
|
1467
|
+
const first = stepNumbers[0];
|
|
1468
|
+
for (const n of stepNumbers) (0, vitest.expect)(n, `E6: step_number must be consistent for ${key}`).toBe(first);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
const deltasByParent = new Map();
|
|
1472
|
+
for (const d of textDeltas) {
|
|
1473
|
+
const key = d.key;
|
|
1474
|
+
const [parentKey, seqStr] = key.split(`:`);
|
|
1475
|
+
if (!deltasByParent.has(parentKey)) deltasByParent.set(parentKey, []);
|
|
1476
|
+
deltasByParent.get(parentKey).push(parseInt(seqStr, 10));
|
|
1477
|
+
}
|
|
1478
|
+
for (const [parent, seqs] of deltasByParent) for (let i = 1; i < seqs.length; i++) (0, vitest.expect)(seqs[i], `E7: text_delta seq for ${parent} must be monotonically increasing`).toBeGreaterThan(seqs[i - 1]);
|
|
1479
|
+
for (const [key, evts] of runs) {
|
|
1480
|
+
const updates = evts.filter((e) => e.headers.operation === `update`);
|
|
1481
|
+
for (const u of updates) {
|
|
1482
|
+
const val = u.value;
|
|
1483
|
+
if (val.status === `completed`) (0, vitest.expect)(val.finish_reason, `E8: run ${key} completion must have finish_reason`).toBeDefined();
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
for (const [key, evts] of steps) {
|
|
1487
|
+
const updates = evts.filter((e) => e.headers.operation === `update`);
|
|
1488
|
+
for (const u of updates) {
|
|
1489
|
+
const val = u.value;
|
|
1490
|
+
if (val.status === `completed`) (0, vitest.expect)(val.finish_reason, `E8: step ${key} completion must have finish_reason`).toBeDefined();
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/cli-dsl.ts
|
|
1497
|
+
function subscriptionEndpoint(baseUrl, id) {
|
|
1498
|
+
return `${baseUrl}/v1/stream-meta/subscriptions/${encodeURIComponent(id)}`;
|
|
1499
|
+
}
|
|
1500
|
+
function subscriptionPattern(pattern) {
|
|
1501
|
+
return pattern.replace(/^\/+/, ``);
|
|
1502
|
+
}
|
|
1503
|
+
var CliScenario = class {
|
|
1504
|
+
baseUrl;
|
|
1505
|
+
cliBin;
|
|
1506
|
+
steps = [];
|
|
1507
|
+
constructor(baseUrl, cliBin) {
|
|
1508
|
+
this.baseUrl = baseUrl;
|
|
1509
|
+
this.cliBin = cliBin;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Register an entity type via HTTP (setup, not a CLI test).
|
|
1513
|
+
*/
|
|
1514
|
+
setupType(registration) {
|
|
1515
|
+
this.steps.push({
|
|
1516
|
+
kind: `setupType`,
|
|
1517
|
+
registration
|
|
1518
|
+
});
|
|
1519
|
+
return this;
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Create a webhook subscription via HTTP (setup, not a CLI test).
|
|
1523
|
+
*/
|
|
1524
|
+
setupSubscription(pattern, id) {
|
|
1525
|
+
this.steps.push({
|
|
1526
|
+
kind: `setupSubscription`,
|
|
1527
|
+
pattern,
|
|
1528
|
+
id
|
|
1529
|
+
});
|
|
1530
|
+
return this;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Verify internal server state via direct API call.
|
|
1534
|
+
* Provides an extra level of guarantee beyond CLI output.
|
|
1535
|
+
*/
|
|
1536
|
+
verifyApi(fn) {
|
|
1537
|
+
this.steps.push({
|
|
1538
|
+
kind: `verifyApi`,
|
|
1539
|
+
fn
|
|
1540
|
+
});
|
|
1541
|
+
return this;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Execute a CLI command. Args are passed directly to the binary.
|
|
1545
|
+
*/
|
|
1546
|
+
exec(...args) {
|
|
1547
|
+
this.steps.push({
|
|
1548
|
+
kind: `exec`,
|
|
1549
|
+
args
|
|
1550
|
+
});
|
|
1551
|
+
return this;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Assert stdout of the last exec matches a regex.
|
|
1555
|
+
*/
|
|
1556
|
+
expectStdout(pattern) {
|
|
1557
|
+
this.steps.push({
|
|
1558
|
+
kind: `expectStdout`,
|
|
1559
|
+
pattern
|
|
1560
|
+
});
|
|
1561
|
+
return this;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Assert stdout of the last exec does NOT match a regex.
|
|
1565
|
+
*/
|
|
1566
|
+
expectStdoutNot(pattern) {
|
|
1567
|
+
this.steps.push({
|
|
1568
|
+
kind: `expectStdoutNot`,
|
|
1569
|
+
pattern
|
|
1570
|
+
});
|
|
1571
|
+
return this;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Assert stdout contains exact text.
|
|
1575
|
+
*/
|
|
1576
|
+
expectStdoutContains(text) {
|
|
1577
|
+
this.steps.push({
|
|
1578
|
+
kind: `expectStdoutContains`,
|
|
1579
|
+
text
|
|
1580
|
+
});
|
|
1581
|
+
return this;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Assert stderr of the last exec matches a regex.
|
|
1585
|
+
*/
|
|
1586
|
+
expectStderr(pattern) {
|
|
1587
|
+
this.steps.push({
|
|
1588
|
+
kind: `expectStderr`,
|
|
1589
|
+
pattern
|
|
1590
|
+
});
|
|
1591
|
+
return this;
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Assert exit code of the last exec.
|
|
1595
|
+
*/
|
|
1596
|
+
expectExitCode(code) {
|
|
1597
|
+
this.steps.push({
|
|
1598
|
+
kind: `expectExitCode`,
|
|
1599
|
+
code
|
|
1600
|
+
});
|
|
1601
|
+
return this;
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Parse stdout as JSON and run a check function.
|
|
1605
|
+
*/
|
|
1606
|
+
expectJson(check) {
|
|
1607
|
+
this.steps.push({
|
|
1608
|
+
kind: `expectJson`,
|
|
1609
|
+
check
|
|
1610
|
+
});
|
|
1611
|
+
return this;
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Custom assertion on the last exec result.
|
|
1615
|
+
*/
|
|
1616
|
+
custom(fn) {
|
|
1617
|
+
this.steps.push({
|
|
1618
|
+
kind: `custom`,
|
|
1619
|
+
fn
|
|
1620
|
+
});
|
|
1621
|
+
return this;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Wait for a given number of milliseconds.
|
|
1625
|
+
*/
|
|
1626
|
+
wait(ms) {
|
|
1627
|
+
this.steps.push({
|
|
1628
|
+
kind: `wait`,
|
|
1629
|
+
ms
|
|
1630
|
+
});
|
|
1631
|
+
return this;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Run all steps sequentially, returning the history.
|
|
1635
|
+
*/
|
|
1636
|
+
async run() {
|
|
1637
|
+
const history = [];
|
|
1638
|
+
let lastResult = {
|
|
1639
|
+
stdout: ``,
|
|
1640
|
+
stderr: ``,
|
|
1641
|
+
exitCode: 0
|
|
1642
|
+
};
|
|
1643
|
+
const receiver = await startNoopReceiver();
|
|
1644
|
+
try {
|
|
1645
|
+
for (const step of this.steps) switch (step.kind) {
|
|
1646
|
+
case `setupType`: {
|
|
1647
|
+
const res = await fetch(`${this.baseUrl}/_electric/entity-types`, {
|
|
1648
|
+
method: `POST`,
|
|
1649
|
+
headers: { "content-type": `application/json` },
|
|
1650
|
+
body: JSON.stringify(step.registration)
|
|
1651
|
+
});
|
|
1652
|
+
(0, vitest.expect)(res.ok, `setupType failed: ${res.status}`).toBe(true);
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
case `setupSubscription`: {
|
|
1656
|
+
const res = await fetch(subscriptionEndpoint(this.baseUrl, step.id), {
|
|
1657
|
+
method: `PUT`,
|
|
1658
|
+
headers: { "content-type": `application/json` },
|
|
1659
|
+
body: JSON.stringify({
|
|
1660
|
+
type: `webhook`,
|
|
1661
|
+
pattern: subscriptionPattern(step.pattern),
|
|
1662
|
+
webhook: { url: receiver.url }
|
|
1663
|
+
})
|
|
1664
|
+
});
|
|
1665
|
+
(0, vitest.expect)(res.status, `setupSubscription failed: ${res.status}`).toBeLessThan(300);
|
|
1666
|
+
break;
|
|
1667
|
+
}
|
|
1668
|
+
case `verifyApi`: {
|
|
1669
|
+
await step.fn(this.baseUrl);
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
case `exec`: {
|
|
1673
|
+
lastResult = await execCli(this.cliBin, step.args, this.baseUrl);
|
|
1674
|
+
history.push({
|
|
1675
|
+
command: step.args,
|
|
1676
|
+
stdout: lastResult.stdout,
|
|
1677
|
+
stderr: lastResult.stderr,
|
|
1678
|
+
exitCode: lastResult.exitCode
|
|
1679
|
+
});
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1682
|
+
case `expectStdout`: {
|
|
1683
|
+
(0, vitest.expect)(lastResult.stdout, `stdout should match ${step.pattern}`).toMatch(step.pattern);
|
|
1684
|
+
break;
|
|
1685
|
+
}
|
|
1686
|
+
case `expectStdoutNot`: {
|
|
1687
|
+
(0, vitest.expect)(lastResult.stdout, `stdout should NOT match ${step.pattern}`).not.toMatch(step.pattern);
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
case `expectStdoutContains`: {
|
|
1691
|
+
(0, vitest.expect)(lastResult.stdout, `stdout should contain "${step.text}"`).toContain(step.text);
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
case `expectStderr`: {
|
|
1695
|
+
(0, vitest.expect)(lastResult.stderr, `stderr should match ${step.pattern}`).toMatch(step.pattern);
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
case `expectExitCode`: {
|
|
1699
|
+
if (lastResult.exitCode !== step.code) {
|
|
1700
|
+
const detail = lastResult.stderr || lastResult.stdout || `(no output)`;
|
|
1701
|
+
(0, vitest.expect)(lastResult.exitCode, `exit code should be ${step.code}, output: ${detail.slice(0, 200)}`).toBe(step.code);
|
|
1702
|
+
}
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
case `expectJson`: {
|
|
1706
|
+
const parsed = JSON.parse(lastResult.stdout);
|
|
1707
|
+
step.check(parsed);
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
case `custom`: {
|
|
1711
|
+
await step.fn(lastResult);
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
case `wait`: {
|
|
1715
|
+
await new Promise((resolve) => setTimeout(resolve, step.ms));
|
|
1716
|
+
break;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
} finally {
|
|
1720
|
+
await receiver.close();
|
|
1721
|
+
}
|
|
1722
|
+
return history;
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
function execCli(bin, args, baseUrl) {
|
|
1726
|
+
return new Promise((resolve) => {
|
|
1727
|
+
(0, node_child_process.execFile)(`pnpm`, [
|
|
1728
|
+
`exec`,
|
|
1729
|
+
`tsx`,
|
|
1730
|
+
bin,
|
|
1731
|
+
...args
|
|
1732
|
+
], {
|
|
1733
|
+
env: {
|
|
1734
|
+
...process.env,
|
|
1735
|
+
ELECTRIC_AGENTS_URL: baseUrl,
|
|
1736
|
+
ELECTRIC_AGENTS_IDENTITY: `test-user@test-host`
|
|
1737
|
+
},
|
|
1738
|
+
timeout: 3e4
|
|
1739
|
+
}, (error, stdout, stderr) => {
|
|
1740
|
+
resolve({
|
|
1741
|
+
stdout: stdout.toString(),
|
|
1742
|
+
stderr: stderr.toString(),
|
|
1743
|
+
exitCode: error ? typeof error.code === `number` ? error.code : 1 : 0
|
|
1744
|
+
});
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
function cliTest(baseUrl, cliBin) {
|
|
1749
|
+
return new CliScenario(baseUrl, cliBin);
|
|
1750
|
+
}
|
|
1751
|
+
function startNoopReceiver() {
|
|
1752
|
+
return new Promise((resolve) => {
|
|
1753
|
+
const server = (0, node_http.createServer)((_req, res) => {
|
|
1754
|
+
_req.on(`data`, () => {});
|
|
1755
|
+
_req.on(`end`, () => {
|
|
1756
|
+
res.writeHead(200, { "content-type": `application/json` });
|
|
1757
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
server.listen(0, `127.0.0.1`, () => {
|
|
1761
|
+
const addr = server.address();
|
|
1762
|
+
if (!addr || typeof addr === `string`) throw new Error(`Failed to start noop receiver`);
|
|
1763
|
+
resolve({
|
|
1764
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
1765
|
+
close: () => new Promise((res) => {
|
|
1766
|
+
server.close(() => res());
|
|
1767
|
+
})
|
|
1768
|
+
});
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
//#endregion
|
|
1774
|
+
//#region src/electric-agents-tests.ts
|
|
1775
|
+
async function electricAgentsFetch(baseUrl, path, opts = {}) {
|
|
1776
|
+
return fetch(`${baseUrl}${routeControlPlanePath(path)}`, {
|
|
1777
|
+
...opts,
|
|
1778
|
+
headers: {
|
|
1779
|
+
"content-type": `application/json`,
|
|
1780
|
+
...opts.headers
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
function routeControlPlanePath(path) {
|
|
1785
|
+
const pathname = path.split(`?`, 1)[0];
|
|
1786
|
+
if (pathname.startsWith(`/_electric/`) || isEntityStreamPath(pathname)) return path;
|
|
1787
|
+
return `/_electric/entities${path}`;
|
|
1788
|
+
}
|
|
1789
|
+
function isEntityStreamPath(pathname) {
|
|
1790
|
+
const segments = pathname.split(`/`).filter(Boolean);
|
|
1791
|
+
const lastSegment = segments.at(-1);
|
|
1792
|
+
return segments.length >= 3 && (lastSegment === `main` || lastSegment === `error`);
|
|
1793
|
+
}
|
|
1794
|
+
async function pollEntityStatus(baseUrl, entityUrl, statuses, timeoutMs = 8e3) {
|
|
1795
|
+
const deadline = Date.now() + timeoutMs;
|
|
1796
|
+
while (Date.now() < deadline) {
|
|
1797
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(entityUrl)}`);
|
|
1798
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
1799
|
+
const entity = await res.json();
|
|
1800
|
+
if (statuses.includes(String(entity.status))) return entity;
|
|
1801
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1802
|
+
}
|
|
1803
|
+
throw new Error(`Timed out waiting for ${entityUrl} to reach one of: ${statuses.join(`, `)}`);
|
|
1804
|
+
}
|
|
1805
|
+
function runElectricAgentsConformanceTests(config) {
|
|
1806
|
+
(0, vitest.describe)(`Electric Agents Spawn`, () => {
|
|
1807
|
+
(0, vitest.test)(`spawn creates entity and streams`, () => electricAgents(config.baseUrl).subscription(`/spawn-test-agent/**`, `spawn-test-sub`).registerType({
|
|
1808
|
+
name: `spawn-test-agent`,
|
|
1809
|
+
description: `Test entity type for spawn`,
|
|
1810
|
+
creation_schema: { type: `object` }
|
|
1811
|
+
}).spawn(`spawn-test-agent`, `entity-1`).expectStatus(`running`).custom(async (ctx) => {
|
|
1812
|
+
const mainRes = await fetch(`${ctx.baseUrl}${ctx.currentEntityStreams.main}`, { method: `HEAD` });
|
|
1813
|
+
(0, vitest.expect)(mainRes.status).toBe(200);
|
|
1814
|
+
const errorRes = await fetch(`${ctx.baseUrl}${ctx.currentEntityStreams.error}`, { method: `HEAD` });
|
|
1815
|
+
(0, vitest.expect)(errorRes.status).toBe(200);
|
|
1816
|
+
}).run());
|
|
1817
|
+
(0, vitest.test)(`spawn at unregistered type returns UNKNOWN_ENTITY_TYPE`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
1818
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/unregistered/entity-1`)}`, {
|
|
1819
|
+
method: `PUT`,
|
|
1820
|
+
headers: { "content-type": `application/json` },
|
|
1821
|
+
body: JSON.stringify({})
|
|
1822
|
+
});
|
|
1823
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
1824
|
+
const body = await res.json();
|
|
1825
|
+
(0, vitest.expect)(body.error.code).toBe(`UNKNOWN_ENTITY_TYPE`);
|
|
1826
|
+
}).skipInvariants().run());
|
|
1827
|
+
(0, vitest.test)(`spawn rejects duplicate URL`, () => electricAgents(config.baseUrl).subscription(`/dup-test-agent/**`, `dup-test-sub`).registerType({
|
|
1828
|
+
name: `dup-test-agent`,
|
|
1829
|
+
description: `Test entity type for duplicate spawn`,
|
|
1830
|
+
creation_schema: { type: `object` }
|
|
1831
|
+
}).spawn(`dup-test-agent`, `entity-1`).expectSpawnError(`/dup-test-agent/entity-1`, `DUPLICATE_URL`, 409).run());
|
|
1832
|
+
(0, vitest.test)(`spawn without type succeeds`, () => electricAgents(config.baseUrl).subscription(`/notype-test-agent/**`, `notype-test-sub`).registerType({
|
|
1833
|
+
name: `notype-test-agent`,
|
|
1834
|
+
description: `Test entity type for typeless spawn`,
|
|
1835
|
+
creation_schema: { type: `object` }
|
|
1836
|
+
}).spawn(`notype-test-agent`, `entity-1`).expectStatus(`running`).custom(async (ctx) => {
|
|
1837
|
+
const res = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl);
|
|
1838
|
+
const entity = await res.json();
|
|
1839
|
+
(0, vitest.expect)(entity.type).toBe(`notype-test-agent`);
|
|
1840
|
+
}).run());
|
|
1841
|
+
});
|
|
1842
|
+
(0, vitest.describe)(`Electric Agents Send`, () => {
|
|
1843
|
+
(0, vitest.test)(`send delivers State Protocol message event`, () => electricAgents(config.baseUrl).subscription(`/send-test-worker/**`, `send-test-sub`).registerType({
|
|
1844
|
+
name: `send-test-worker`,
|
|
1845
|
+
description: `Test entity type for send`,
|
|
1846
|
+
creation_schema: { type: `object` }
|
|
1847
|
+
}).spawn(`send-test-worker`, `entity-1`).send({ task: `hello` }, { from: `user-1` }).expectWebhook().respondDone().readStream().expectStreamContains(`message_received`).custom(async (ctx) => {
|
|
1848
|
+
const envelope = ctx.lastStreamMessages.find((m) => m.type === `message_received`);
|
|
1849
|
+
(0, vitest.expect)(envelope.type).toBe(`message_received`);
|
|
1850
|
+
(0, vitest.expect)(envelope.key).toBeDefined();
|
|
1851
|
+
(0, vitest.expect)(envelope.value?.from).toBe(`user-1`);
|
|
1852
|
+
(0, vitest.expect)(envelope.value?.payload).toEqual({ task: `hello` });
|
|
1853
|
+
}).run());
|
|
1854
|
+
(0, vitest.test)(`send triggers webhook with entity context`, () => electricAgents(config.baseUrl).subscription(`/webhook-ctx-agent/**`, `webhook-ctx-sub`).registerType({
|
|
1855
|
+
name: `webhook-ctx-agent`,
|
|
1856
|
+
description: `Test entity type for webhook context`,
|
|
1857
|
+
creation_schema: { type: `object` }
|
|
1858
|
+
}).spawn(`webhook-ctx-agent`, `entity-1`).send({ ping: true }, { from: `test` }).expectWebhook().expectEntityContext({ type: `webhook-ctx-agent` }).respondDone().run());
|
|
1859
|
+
(0, vitest.test)(`send to nonexistent entity returns 404`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
1860
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/nonexistent-type/nonexistent-id/send`)}`, {
|
|
1861
|
+
method: `POST`,
|
|
1862
|
+
headers: { "content-type": `application/json` },
|
|
1863
|
+
body: JSON.stringify({
|
|
1864
|
+
from: `test`,
|
|
1865
|
+
payload: {}
|
|
1866
|
+
})
|
|
1867
|
+
});
|
|
1868
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
1869
|
+
}).skipInvariants().run());
|
|
1870
|
+
(0, vitest.test)(`multiple sends preserve order in stream`, () => {
|
|
1871
|
+
const id = Date.now();
|
|
1872
|
+
return electricAgents(config.baseUrl).subscription(`/order-test-agent-${id}/**`, `order-test-sub-${id}`).registerType({
|
|
1873
|
+
name: `order-test-agent-${id}`,
|
|
1874
|
+
description: `Test entity type for ordering`,
|
|
1875
|
+
creation_schema: { type: `object` }
|
|
1876
|
+
}).spawn(`order-test-agent-${id}`, `entity-1`).send({ seq: 1 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 2 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 3 }, { from: `test` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
1877
|
+
const msgs = ctx.lastStreamMessages.filter((m) => m.type === `message_received`);
|
|
1878
|
+
(0, vitest.expect)(msgs.length).toBe(3);
|
|
1879
|
+
for (let i = 0; i < 3; i++) (0, vitest.expect)(msgs[i].value?.payload).toEqual({ seq: i + 1 });
|
|
1880
|
+
}).run();
|
|
1881
|
+
});
|
|
1882
|
+
(0, vitest.test)(`send without from is rejected`, () => electricAgents(config.baseUrl).subscription(`/nofrom-test-agent/**`, `nofrom-test-sub`).registerType({
|
|
1883
|
+
name: `nofrom-test-agent`,
|
|
1884
|
+
description: `Test entity type for send without from`,
|
|
1885
|
+
creation_schema: { type: `object` }
|
|
1886
|
+
}).spawn(`nofrom-test-agent`, `entity-1`).custom(async (ctx) => {
|
|
1887
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
|
|
1888
|
+
method: `POST`,
|
|
1889
|
+
body: JSON.stringify({ payload: { hello: true } })
|
|
1890
|
+
});
|
|
1891
|
+
(0, vitest.expect)(res.status).toBe(400);
|
|
1892
|
+
const body = await res.json();
|
|
1893
|
+
(0, vitest.expect)(body.error.code).toBe(`INVALID_REQUEST`);
|
|
1894
|
+
}).run());
|
|
1895
|
+
});
|
|
1896
|
+
(0, vitest.describe)(`Electric Agents List`, () => {
|
|
1897
|
+
(0, vitest.test)(`ps lists entities`, () => electricAgents(config.baseUrl).subscription(`/ps-test-worker/**`, `ps-test-worker-sub`).subscription(`/ps-test-agent/**`, `ps-test-agent-sub`).registerType({
|
|
1898
|
+
name: `ps-test-worker`,
|
|
1899
|
+
description: `Test worker type for ps`,
|
|
1900
|
+
creation_schema: { type: `object` }
|
|
1901
|
+
}).registerType({
|
|
1902
|
+
name: `ps-test-agent`,
|
|
1903
|
+
description: `Test agent type for ps`,
|
|
1904
|
+
creation_schema: { type: `object` }
|
|
1905
|
+
}).spawn(`ps-test-worker`, `entity-1`).spawn(`ps-test-agent`, `entity-2`).list().expectListCount({ min: 2 }).list({ type: `ps-test-agent` }).custom(async (ctx) => {
|
|
1906
|
+
(0, vitest.expect)(ctx.lastListResult.every((e) => e.type === `ps-test-agent`)).toBe(true);
|
|
1907
|
+
}).run());
|
|
1908
|
+
(0, vitest.test)(`list with status filter`, () => {
|
|
1909
|
+
return electricAgents(config.baseUrl).subscription(`/status-filter-agent/**`, `status-filter-sub`).registerType({
|
|
1910
|
+
name: `status-filter-agent`,
|
|
1911
|
+
description: `Test entity type for status filter`,
|
|
1912
|
+
creation_schema: { type: `object` }
|
|
1913
|
+
}).spawn(`status-filter-agent`, `entity-1`).spawn(`status-filter-agent`, `entity-2`).kill().list({ status: `stopped` }).custom(async (ctx) => {
|
|
1914
|
+
(0, vitest.expect)(ctx.lastListResult.every((e) => e.status === `stopped`)).toBe(true);
|
|
1915
|
+
}).list({ status: `running` }).custom(async (ctx) => {
|
|
1916
|
+
(0, vitest.expect)(ctx.lastListResult.every((e) => [`running`, `idle`].includes(e.status))).toBe(true);
|
|
1917
|
+
}).run();
|
|
1918
|
+
});
|
|
1919
|
+
(0, vitest.test)(`get nonexistent entity returns 404`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
1920
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `/does-not-exist-type/does-not-exist`);
|
|
1921
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
1922
|
+
}).skipInvariants().run());
|
|
1923
|
+
});
|
|
1924
|
+
(0, vitest.describe)(`Electric Agents Kill`, () => {
|
|
1925
|
+
(0, vitest.test)(`kill stops entity and rejects further sends`, () => electricAgents(config.baseUrl).subscription(`/kill-test-agent/**`, `kill-test-sub`).registerType({
|
|
1926
|
+
name: `kill-test-agent`,
|
|
1927
|
+
description: `Test entity type for kill`,
|
|
1928
|
+
creation_schema: { type: `object` }
|
|
1929
|
+
}).spawn(`kill-test-agent`, `entity-1`).kill().expectStatus(`stopped`).expectSendError(`NOT_RUNNING`, 409).run());
|
|
1930
|
+
(0, vitest.test)(`stream data persists after kill`, () => electricAgents(config.baseUrl).subscription(`/kill-persist-agent/**`, `kill-persist-sub`).registerType({
|
|
1931
|
+
name: `kill-persist-agent`,
|
|
1932
|
+
description: `Test entity type for kill persistence`,
|
|
1933
|
+
creation_schema: { type: `object` }
|
|
1934
|
+
}).spawn(`kill-persist-agent`, `entity-1`).send({ before: `kill` }, { from: `test` }).expectWebhook().respondDone().kill().readStream().expectStreamContains(`message_received`).custom(async (ctx) => {
|
|
1935
|
+
const msgs = ctx.lastStreamMessages;
|
|
1936
|
+
const msgReceived = msgs.find((m) => m.type === `message_received`);
|
|
1937
|
+
(0, vitest.expect)(msgReceived.value?.payload).toEqual({ before: `kill` });
|
|
1938
|
+
const stopped = msgs.find((m) => m.type === `entity_stopped`);
|
|
1939
|
+
(0, vitest.expect)(stopped).toBeDefined();
|
|
1940
|
+
}).run());
|
|
1941
|
+
vitest.test.skip(`multiple entities under same subscription`, () => {
|
|
1942
|
+
let firstEntityUrl = null;
|
|
1943
|
+
let secondEntityUrl = null;
|
|
1944
|
+
return electricAgents(config.baseUrl).subscription(`/multi-test-worker/**`, `multi-test-sub`).registerType({
|
|
1945
|
+
name: `multi-test-worker`,
|
|
1946
|
+
description: `Test entity type for multi-entity`,
|
|
1947
|
+
creation_schema: { type: `object` }
|
|
1948
|
+
}).spawn(`multi-test-worker`, `entity-1`).custom(async (ctx) => {
|
|
1949
|
+
firstEntityUrl = ctx.currentEntityUrl;
|
|
1950
|
+
}).spawn(`multi-test-worker`, `entity-2`).custom(async (ctx) => {
|
|
1951
|
+
secondEntityUrl = ctx.currentEntityUrl;
|
|
1952
|
+
}).custom(async (ctx) => {
|
|
1953
|
+
const res = await electricAgentsFetch(ctx.baseUrl, firstEntityUrl, { method: `DELETE` });
|
|
1954
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
1955
|
+
ctx.history.push({
|
|
1956
|
+
type: `entity_killed`,
|
|
1957
|
+
entityUrl: firstEntityUrl
|
|
1958
|
+
});
|
|
1959
|
+
}).custom(async (ctx) => {
|
|
1960
|
+
const res1 = await electricAgentsFetch(ctx.baseUrl, firstEntityUrl);
|
|
1961
|
+
const e1 = await res1.json();
|
|
1962
|
+
(0, vitest.expect)(e1.status).toBe(`stopped`);
|
|
1963
|
+
const res2 = await electricAgentsFetch(ctx.baseUrl, secondEntityUrl);
|
|
1964
|
+
const e2 = await res2.json();
|
|
1965
|
+
(0, vitest.expect)([`running`, `idle`]).toContain(e2.status);
|
|
1966
|
+
}).run();
|
|
1967
|
+
});
|
|
1968
|
+
});
|
|
1969
|
+
(0, vitest.describe)(`Electric Agents E2E`, () => {
|
|
1970
|
+
(0, vitest.test)(`full lifecycle: spawn → send → webhook → read → kill`, () => electricAgents(config.baseUrl).subscription(`/e2e-test-agent/**`, `e2e-test-sub`).registerType({
|
|
1971
|
+
name: `e2e-test-agent`,
|
|
1972
|
+
description: `Test entity type for E2E lifecycle`,
|
|
1973
|
+
creation_schema: { type: `object` }
|
|
1974
|
+
}).spawn(`e2e-test-agent`, `entity-1`).expectStatus(`running`).send({ task: `do-something` }, { from: `user-1` }).expectWebhook().expectEntityContext({ type: `e2e-test-agent` }).respondDone().readStream().expectStreamContains(`message_received`).kill().custom(async (ctx) => {
|
|
1975
|
+
const entity = await pollEntityStatus(ctx.baseUrl, ctx.currentEntityUrl, [`stopped`]);
|
|
1976
|
+
(0, vitest.expect)(entity.status).toBe(`stopped`);
|
|
1977
|
+
}).expectSendError(`NOT_RUNNING`, 409).run());
|
|
1978
|
+
});
|
|
1979
|
+
(0, vitest.describe)(`Electric Agents Entity Type Registration`, () => {
|
|
1980
|
+
(0, vitest.test)(`register entity type`, () => electricAgents(config.baseUrl).subscription(`/reg-type-test/**`, `reg-type-test-sub`).registerType({
|
|
1981
|
+
name: `reg-full-type-${Date.now()}`,
|
|
1982
|
+
description: `A fully-specified entity type`,
|
|
1983
|
+
creation_schema: {
|
|
1984
|
+
type: `object`,
|
|
1985
|
+
properties: { name: { type: `string` } }
|
|
1986
|
+
},
|
|
1987
|
+
input_schemas: { query: {
|
|
1988
|
+
type: `object`,
|
|
1989
|
+
properties: { text: { type: `string` } },
|
|
1990
|
+
required: [`text`]
|
|
1991
|
+
} },
|
|
1992
|
+
output_schemas: { result: {
|
|
1993
|
+
type: `object`,
|
|
1994
|
+
properties: { answer: { type: `string` } }
|
|
1995
|
+
} }
|
|
1996
|
+
}).custom(async (ctx) => {
|
|
1997
|
+
const typeEvents = ctx.history.filter((h) => h.type === `entity_type_registered`);
|
|
1998
|
+
(0, vitest.expect)(typeEvents.length).toBeGreaterThanOrEqual(1);
|
|
1999
|
+
const registered = typeEvents[0];
|
|
2000
|
+
(0, vitest.expect)(registered.type).toBe(`entity_type_registered`);
|
|
2001
|
+
}).run());
|
|
2002
|
+
(0, vitest.test)(`list entity types`, () => {
|
|
2003
|
+
const suffix = Date.now();
|
|
2004
|
+
return electricAgents(config.baseUrl).subscription(`/list-types-test/**`, `list-types-sub`).registerType({
|
|
2005
|
+
name: `list-type-a-${suffix}`,
|
|
2006
|
+
description: `First type for listing`,
|
|
2007
|
+
creation_schema: { type: `object` }
|
|
2008
|
+
}).registerType({
|
|
2009
|
+
name: `list-type-b-${suffix}`,
|
|
2010
|
+
description: `Second type for listing`,
|
|
2011
|
+
creation_schema: { type: `object` }
|
|
2012
|
+
}).listTypes().custom(async (ctx) => {
|
|
2013
|
+
(0, vitest.expect)(ctx.lastTypeListResult).toBeDefined();
|
|
2014
|
+
const names = ctx.lastTypeListResult.map((t) => t.name);
|
|
2015
|
+
(0, vitest.expect)(names).toContain(`list-type-a-${suffix}`);
|
|
2016
|
+
(0, vitest.expect)(names).toContain(`list-type-b-${suffix}`);
|
|
2017
|
+
}).run();
|
|
2018
|
+
});
|
|
2019
|
+
(0, vitest.test)(`inspect entity type`, () => {
|
|
2020
|
+
const typeName = `inspect-type-${Date.now()}`;
|
|
2021
|
+
return electricAgents(config.baseUrl).subscription(`/inspect-type-test/**`, `inspect-type-sub`).registerType({
|
|
2022
|
+
name: typeName,
|
|
2023
|
+
description: `Type for inspection`,
|
|
2024
|
+
creation_schema: {
|
|
2025
|
+
type: `object`,
|
|
2026
|
+
properties: { x: { type: `number` } }
|
|
2027
|
+
},
|
|
2028
|
+
input_schemas: { ping: {
|
|
2029
|
+
type: `object`,
|
|
2030
|
+
properties: { msg: { type: `string` } }
|
|
2031
|
+
} }
|
|
2032
|
+
}).inspectType(typeName).custom(async (ctx) => {
|
|
2033
|
+
(0, vitest.expect)(ctx.lastTypeResult).toBeDefined();
|
|
2034
|
+
(0, vitest.expect)(ctx.lastTypeResult.name).toBe(typeName);
|
|
2035
|
+
(0, vitest.expect)(ctx.lastTypeResult.creation_schema).toBeDefined();
|
|
2036
|
+
(0, vitest.expect)(ctx.lastTypeResult.inbox_schemas).toBeDefined();
|
|
2037
|
+
}).run();
|
|
2038
|
+
});
|
|
2039
|
+
(0, vitest.test)(`delete entity type`, () => {
|
|
2040
|
+
const typeName = `delete-type-${Date.now()}`;
|
|
2041
|
+
return electricAgents(config.baseUrl).subscription(`/delete-type-test/**`, `delete-type-sub`).registerType({
|
|
2042
|
+
name: typeName,
|
|
2043
|
+
description: `Type to be deleted`,
|
|
2044
|
+
creation_schema: { type: `object` }
|
|
2045
|
+
}).deleteType(typeName).expectTypeNotExists(typeName).run();
|
|
2046
|
+
});
|
|
2047
|
+
(0, vitest.test)(`register upserts duplicate name`, () => {
|
|
2048
|
+
const typeName = `dup-type-${Date.now()}`;
|
|
2049
|
+
return electricAgents(config.baseUrl).subscription(`/dup-type-test/**`, `dup-type-sub`).registerType({
|
|
2050
|
+
name: typeName,
|
|
2051
|
+
description: `First registration`,
|
|
2052
|
+
creation_schema: { type: `object` }
|
|
2053
|
+
}).custom(async (ctx) => {
|
|
2054
|
+
const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
|
|
2055
|
+
method: `POST`,
|
|
2056
|
+
headers: { "content-type": `application/json` },
|
|
2057
|
+
body: JSON.stringify({
|
|
2058
|
+
name: typeName,
|
|
2059
|
+
description: `Duplicate registration`,
|
|
2060
|
+
creation_schema: { type: `object` }
|
|
2061
|
+
})
|
|
2062
|
+
});
|
|
2063
|
+
(0, vitest.expect)(res.status).toBe(201);
|
|
2064
|
+
const body = await res.json();
|
|
2065
|
+
(0, vitest.expect)(body.name).toBe(typeName);
|
|
2066
|
+
(0, vitest.expect)(body.description).toBe(`Duplicate registration`);
|
|
2067
|
+
(0, vitest.expect)(body.revision).toBe(2);
|
|
2068
|
+
}).run();
|
|
2069
|
+
});
|
|
2070
|
+
(0, vitest.test)(`register rejects missing required fields`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
2071
|
+
const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
|
|
2072
|
+
method: `POST`,
|
|
2073
|
+
headers: { "content-type": `application/json` },
|
|
2074
|
+
body: JSON.stringify({})
|
|
2075
|
+
});
|
|
2076
|
+
(0, vitest.expect)([400, 422]).toContain(res.status);
|
|
2077
|
+
}).skipInvariants().run());
|
|
2078
|
+
(0, vitest.test)(`register entity type without creation_schema`, () => {
|
|
2079
|
+
const typeName = `minimal-type-${Date.now()}`;
|
|
2080
|
+
return electricAgents(config.baseUrl).subscription(`/minimal-type-test/**`, `minimal-type-sub`).registerType({
|
|
2081
|
+
name: typeName,
|
|
2082
|
+
description: `A minimal entity type with no schemas`
|
|
2083
|
+
}).inspectType(typeName).custom(async (ctx) => {
|
|
2084
|
+
(0, vitest.expect)(ctx.lastTypeResult).toBeDefined();
|
|
2085
|
+
(0, vitest.expect)(ctx.lastTypeResult.name).toBe(typeName);
|
|
2086
|
+
(0, vitest.expect)(ctx.lastTypeResult.description).toBe(`A minimal entity type with no schemas`);
|
|
2087
|
+
}).run();
|
|
2088
|
+
});
|
|
2089
|
+
});
|
|
2090
|
+
(0, vitest.describe)(`Electric Agents Typed Spawn`, () => {
|
|
2091
|
+
(0, vitest.test)(`typed spawn validates creation_schema`, () => {
|
|
2092
|
+
const typeName = `typed-spawn-valid-${Date.now()}`;
|
|
2093
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `typed-spawn-sub`).registerType({
|
|
2094
|
+
name: typeName,
|
|
2095
|
+
description: `Type with creation schema`,
|
|
2096
|
+
creation_schema: {
|
|
2097
|
+
type: `object`,
|
|
2098
|
+
properties: { topic: { type: `string` } },
|
|
2099
|
+
required: [`topic`]
|
|
2100
|
+
}
|
|
2101
|
+
}).spawn(typeName, `entity-1`, { args: { topic: `CRDTs` } }).expectStatus(`running`).run();
|
|
2102
|
+
});
|
|
2103
|
+
(0, vitest.test)(`typed spawn rejects invalid args (C10)`, () => {
|
|
2104
|
+
const typeName = `typed-spawn-invalid-${Date.now()}`;
|
|
2105
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `typed-spawn-inv-sub`).registerType({
|
|
2106
|
+
name: typeName,
|
|
2107
|
+
description: `Type with strict creation schema`,
|
|
2108
|
+
creation_schema: {
|
|
2109
|
+
type: `object`,
|
|
2110
|
+
properties: { topic: { type: `string` } },
|
|
2111
|
+
required: [`topic`]
|
|
2112
|
+
}
|
|
2113
|
+
}).expectSpawnSchemaError(typeName, `bad-entity-1`, { args: { invalid: true } }).run();
|
|
2114
|
+
});
|
|
2115
|
+
(0, vitest.test)(`typed spawn at unregistered type returns UNKNOWN_ENTITY_TYPE (C9)`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
2116
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/nonexistent/should-fail`)}`, {
|
|
2117
|
+
method: `PUT`,
|
|
2118
|
+
headers: { "content-type": `application/json` },
|
|
2119
|
+
body: JSON.stringify({})
|
|
2120
|
+
});
|
|
2121
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
2122
|
+
const body = await res.json();
|
|
2123
|
+
(0, vitest.expect)(body.error.code).toBe(`UNKNOWN_ENTITY_TYPE`);
|
|
2124
|
+
}).skipInvariants().run());
|
|
2125
|
+
(0, vitest.test)(`spawn with parent`, () => {
|
|
2126
|
+
const typeName = `parent-spawn-${Date.now()}`;
|
|
2127
|
+
let parentUrl = null;
|
|
2128
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `parent-spawn-sub`).registerType({
|
|
2129
|
+
name: typeName,
|
|
2130
|
+
description: `Type for parent-child test`,
|
|
2131
|
+
creation_schema: { type: `object` }
|
|
2132
|
+
}).spawn(typeName, `parent-entity`).custom(async (ctx) => {
|
|
2133
|
+
parentUrl = ctx.currentEntityUrl;
|
|
2134
|
+
}).custom(async (ctx) => {
|
|
2135
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/child-entity`)}`, {
|
|
2136
|
+
method: `PUT`,
|
|
2137
|
+
headers: { "content-type": `application/json` },
|
|
2138
|
+
body: JSON.stringify({ parent: parentUrl })
|
|
2139
|
+
});
|
|
2140
|
+
(0, vitest.expect)(res.status).toBe(201);
|
|
2141
|
+
const entity = await res.json();
|
|
2142
|
+
ctx.currentEntityUrl = entity.url;
|
|
2143
|
+
ctx.currentEntityStreams = entity.streams;
|
|
2144
|
+
ctx.currentWriteToken = null;
|
|
2145
|
+
ctx.history.push({
|
|
2146
|
+
type: `entity_spawned`,
|
|
2147
|
+
entityUrl: entity.url,
|
|
2148
|
+
entityType: typeName,
|
|
2149
|
+
status: entity.status,
|
|
2150
|
+
streams: entity.streams,
|
|
2151
|
+
parent: parentUrl
|
|
2152
|
+
});
|
|
2153
|
+
}).expectStatus(`running`).run();
|
|
2154
|
+
});
|
|
2155
|
+
(0, vitest.test)(`spawn with parent rejects missing parent`, () => {
|
|
2156
|
+
const typeName = `orphan-spawn-${Date.now()}`;
|
|
2157
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `orphan-spawn-sub`).registerType({
|
|
2158
|
+
name: typeName,
|
|
2159
|
+
description: `Type for orphan spawn test`,
|
|
2160
|
+
creation_schema: { type: `object` }
|
|
2161
|
+
}).custom(async (ctx) => {
|
|
2162
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/orphan-entity`)}`, {
|
|
2163
|
+
method: `PUT`,
|
|
2164
|
+
headers: { "content-type": `application/json` },
|
|
2165
|
+
body: JSON.stringify({ parent: `/nonexistent/parent` })
|
|
2166
|
+
});
|
|
2167
|
+
(0, vitest.expect)([
|
|
2168
|
+
400,
|
|
2169
|
+
404,
|
|
2170
|
+
422
|
|
2171
|
+
]).toContain(res.status);
|
|
2172
|
+
}).run();
|
|
2173
|
+
});
|
|
2174
|
+
(0, vitest.test)(`spawn with explicit tags sets tags`, () => {
|
|
2175
|
+
const typeName = `tags-spawn-${Date.now()}`;
|
|
2176
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `meta-spawn-sub`).registerType({
|
|
2177
|
+
name: typeName,
|
|
2178
|
+
description: `Type for tag spawn test`
|
|
2179
|
+
}).spawn(typeName, `inst-1`, { tags: {
|
|
2180
|
+
color: `blue`,
|
|
2181
|
+
count: `42`
|
|
2182
|
+
} }).custom(async (ctx) => {
|
|
2183
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
|
|
2184
|
+
const entity = await res.json();
|
|
2185
|
+
const tags = entity.tags;
|
|
2186
|
+
(0, vitest.expect)(tags.color).toBe(`blue`);
|
|
2187
|
+
(0, vitest.expect)(tags.count).toBe(`42`);
|
|
2188
|
+
}).run();
|
|
2189
|
+
});
|
|
2190
|
+
(0, vitest.test)(`spawn rejects non-string tags`, () => {
|
|
2191
|
+
const id = Date.now();
|
|
2192
|
+
const typeName = `tags-invalid-spawn-${id}`;
|
|
2193
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-invalid-spawn-sub-${id}`).registerType({
|
|
2194
|
+
name: typeName,
|
|
2195
|
+
description: `Type with string-only tags`
|
|
2196
|
+
}).custom(async (ctx) => {
|
|
2197
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/should-fail`)}`, {
|
|
2198
|
+
method: `PUT`,
|
|
2199
|
+
headers: { "content-type": `application/json` },
|
|
2200
|
+
body: JSON.stringify({ tags: { wrong: 123 } })
|
|
2201
|
+
});
|
|
2202
|
+
(0, vitest.expect)(res.status).toBe(400);
|
|
2203
|
+
}).run();
|
|
2204
|
+
});
|
|
2205
|
+
(0, vitest.test)(`spawn without explicit tags defaults to empty tags`, () => {
|
|
2206
|
+
const typeName = `tags-default-${Date.now()}`;
|
|
2207
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-default-sub`).registerType({
|
|
2208
|
+
name: typeName,
|
|
2209
|
+
description: `Type with default empty tags`
|
|
2210
|
+
}).spawn(typeName, `entity-1`).custom(async (ctx) => {
|
|
2211
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
|
|
2212
|
+
const entity = await res.json();
|
|
2213
|
+
(0, vitest.expect)(entity.tags).toEqual({});
|
|
2214
|
+
}).run();
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2217
|
+
(0, vitest.describe)(`Electric Agents Schema Validation Gates`, () => {
|
|
2218
|
+
(0, vitest.test)(`send validates input_schemas (C11)`, () => {
|
|
2219
|
+
const typeName = `send-schema-valid-${Date.now()}`;
|
|
2220
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-schema-sub`).registerType({
|
|
2221
|
+
name: typeName,
|
|
2222
|
+
description: `Type with input schemas`,
|
|
2223
|
+
creation_schema: { type: `object` },
|
|
2224
|
+
input_schemas: { query: {
|
|
2225
|
+
type: `object`,
|
|
2226
|
+
properties: { text: { type: `string` } },
|
|
2227
|
+
required: [`text`]
|
|
2228
|
+
} }
|
|
2229
|
+
}).spawn(typeName, `entity-1`).send({ text: `hello` }, {
|
|
2230
|
+
from: `user`,
|
|
2231
|
+
type: `query`
|
|
2232
|
+
}).expectWebhook().respondDone().run();
|
|
2233
|
+
});
|
|
2234
|
+
(0, vitest.test)(`send rejects invalid typed message (C11)`, () => {
|
|
2235
|
+
const typeName = `send-schema-inv-${Date.now()}`;
|
|
2236
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-schema-inv-sub`).registerType({
|
|
2237
|
+
name: typeName,
|
|
2238
|
+
description: `Type with strict input schemas`,
|
|
2239
|
+
creation_schema: { type: `object` },
|
|
2240
|
+
input_schemas: { query: {
|
|
2241
|
+
type: `object`,
|
|
2242
|
+
properties: { text: { type: `string` } },
|
|
2243
|
+
required: [`text`]
|
|
2244
|
+
} }
|
|
2245
|
+
}).spawn(typeName, `entity-1`).expectSendSchemaError({ invalid: true }, {
|
|
2246
|
+
from: `user`,
|
|
2247
|
+
type: `query`
|
|
2248
|
+
}).run();
|
|
2249
|
+
});
|
|
2250
|
+
(0, vitest.test)(`send rejects unknown message type (C13)`, () => {
|
|
2251
|
+
const typeName = `send-unknown-type-${Date.now()}`;
|
|
2252
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-unknown-sub`).registerType({
|
|
2253
|
+
name: typeName,
|
|
2254
|
+
description: `Type with defined input schemas`,
|
|
2255
|
+
creation_schema: { type: `object` },
|
|
2256
|
+
input_schemas: { query: {
|
|
2257
|
+
type: `object`,
|
|
2258
|
+
properties: { text: { type: `string` } },
|
|
2259
|
+
required: [`text`]
|
|
2260
|
+
} }
|
|
2261
|
+
}).spawn(typeName, `entity-1`).expectSendUnknownType({ text: `hi` }, {
|
|
2262
|
+
from: `user`,
|
|
2263
|
+
type: `unknown_type`
|
|
2264
|
+
}).run();
|
|
2265
|
+
});
|
|
2266
|
+
(0, vitest.test)(`send without type when no input_schemas accepts any`, () => {
|
|
2267
|
+
const typeName = `send-no-schemas-${Date.now()}`;
|
|
2268
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-noschema-sub`).registerType({
|
|
2269
|
+
name: typeName,
|
|
2270
|
+
description: `Type without input schemas`,
|
|
2271
|
+
creation_schema: { type: `object` }
|
|
2272
|
+
}).spawn(typeName, `entity-1`).send({ anything: `goes` }, { from: `user` }).expectWebhook().respondDone().run();
|
|
2273
|
+
});
|
|
2274
|
+
(0, vitest.test)(`send with empty input_schemas rejects all`, () => {
|
|
2275
|
+
const typeName = `send-empty-schemas-${Date.now()}`;
|
|
2276
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-empty-sub`).registerType({
|
|
2277
|
+
name: typeName,
|
|
2278
|
+
description: `Type with empty input schemas`,
|
|
2279
|
+
creation_schema: { type: `object` },
|
|
2280
|
+
input_schemas: {}
|
|
2281
|
+
}).spawn(typeName, `entity-1`).expectSendUnknownType({ text: `anything` }, {
|
|
2282
|
+
from: `user`,
|
|
2283
|
+
type: `some_type`
|
|
2284
|
+
}).run();
|
|
2285
|
+
});
|
|
2286
|
+
vitest.test.skip(`write appends event to entity stream`, () => {
|
|
2287
|
+
const typeName = `write-append-${Date.now()}`;
|
|
2288
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-append-sub`).registerType({
|
|
2289
|
+
name: typeName,
|
|
2290
|
+
description: `Type for write test`,
|
|
2291
|
+
creation_schema: { type: `object` },
|
|
2292
|
+
output_schemas: { research_result: {
|
|
2293
|
+
type: `object`,
|
|
2294
|
+
properties: { findings: {
|
|
2295
|
+
type: `array`,
|
|
2296
|
+
items: { type: `string` }
|
|
2297
|
+
} }
|
|
2298
|
+
} }
|
|
2299
|
+
}).spawn(typeName, `entity-1`).write({ findings: [`test`] }, { type: `research_result` }).readStream().expectStreamContains(`research_result`).run();
|
|
2300
|
+
});
|
|
2301
|
+
vitest.test.skip(`write validates output_schemas (C12)`, () => {
|
|
2302
|
+
const typeName = `write-schema-inv-${Date.now()}`;
|
|
2303
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-schema-sub`).registerType({
|
|
2304
|
+
name: typeName,
|
|
2305
|
+
description: `Type with strict output schemas`,
|
|
2306
|
+
creation_schema: { type: `object` },
|
|
2307
|
+
output_schemas: { result: {
|
|
2308
|
+
type: `object`,
|
|
2309
|
+
properties: { value: { type: `number` } },
|
|
2310
|
+
required: [`value`]
|
|
2311
|
+
} }
|
|
2312
|
+
}).spawn(typeName, `entity-1`).expectWriteSchemaError({ wrong: `type` }, { type: `result` }).run();
|
|
2313
|
+
});
|
|
2314
|
+
vitest.test.skip(`write rejects unknown event type (C14)`, () => {
|
|
2315
|
+
const typeName = `write-unknown-type-${Date.now()}`;
|
|
2316
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-unknown-sub`).registerType({
|
|
2317
|
+
name: typeName,
|
|
2318
|
+
description: `Type with defined output schemas`,
|
|
2319
|
+
creation_schema: { type: `object` },
|
|
2320
|
+
output_schemas: { result: {
|
|
2321
|
+
type: `object`,
|
|
2322
|
+
properties: { value: { type: `number` } }
|
|
2323
|
+
} }
|
|
2324
|
+
}).spawn(typeName, `entity-1`).expectWriteUnknownType({ data: `test` }, { type: `unknown_event` }).run();
|
|
2325
|
+
});
|
|
2326
|
+
vitest.test.skip(`write without type when no output_schemas accepts any`, () => {
|
|
2327
|
+
const typeName = `write-no-schemas-${Date.now()}`;
|
|
2328
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-noschema-sub`).registerType({
|
|
2329
|
+
name: typeName,
|
|
2330
|
+
description: `Type without output schemas`,
|
|
2331
|
+
creation_schema: { type: `object` }
|
|
2332
|
+
}).spawn(typeName, `entity-1`).write({ anything: `goes` }).run();
|
|
2333
|
+
});
|
|
2334
|
+
vitest.test.skip(`write to stopped entity rejected`, () => {
|
|
2335
|
+
const typeName = `write-stopped-${Date.now()}`;
|
|
2336
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-stopped-sub`).registerType({
|
|
2337
|
+
name: typeName,
|
|
2338
|
+
description: `Type for stopped write test`,
|
|
2339
|
+
creation_schema: { type: `object` }
|
|
2340
|
+
}).spawn(typeName, `entity-1`).kill().custom(async (ctx) => {
|
|
2341
|
+
const writeHeaders = { "content-type": `application/json` };
|
|
2342
|
+
if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
2343
|
+
const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
|
|
2344
|
+
method: `POST`,
|
|
2345
|
+
headers: writeHeaders,
|
|
2346
|
+
body: JSON.stringify({
|
|
2347
|
+
type: `test`,
|
|
2348
|
+
key: `stopped-test`,
|
|
2349
|
+
value: { should: `fail` },
|
|
2350
|
+
headers: { operation: `insert` }
|
|
2351
|
+
})
|
|
2352
|
+
});
|
|
2353
|
+
(0, vitest.expect)(res.status).toBe(409);
|
|
2354
|
+
}).run();
|
|
2355
|
+
});
|
|
2356
|
+
vitest.test.skip(`write accepts State Protocol format (type/key/value/headers)`, () => {
|
|
2357
|
+
const typeName = `sp-write-agent-${Date.now()}`;
|
|
2358
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `sp-write-sub`).registerType({
|
|
2359
|
+
name: typeName,
|
|
2360
|
+
description: `Test State Protocol write format`
|
|
2361
|
+
}).spawn(typeName, `sp-w-1`).writeStateProtocol({
|
|
2362
|
+
type: `text`,
|
|
2363
|
+
key: `msg-1`,
|
|
2364
|
+
value: { status: `streaming` },
|
|
2365
|
+
headers: { operation: `insert` }
|
|
2366
|
+
}).readStream().custom(async (ctx) => {
|
|
2367
|
+
const events = ctx.lastStreamMessages;
|
|
2368
|
+
const textEvent = events.find((e) => e.type === `text` && e.key === `msg-1`);
|
|
2369
|
+
(0, vitest.expect)(textEvent).toBeDefined();
|
|
2370
|
+
(0, vitest.expect)(textEvent.value.status).toBe(`streaming`);
|
|
2371
|
+
(0, vitest.expect)(textEvent.headers.operation).toBe(`insert`);
|
|
2372
|
+
}).kill().run();
|
|
2373
|
+
});
|
|
2374
|
+
vitest.test.skip(`update tags`, () => {
|
|
2375
|
+
const typeName = `tags-update-${Date.now()}`;
|
|
2376
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-update-sub`).registerType({
|
|
2377
|
+
name: typeName,
|
|
2378
|
+
description: `Type for tag update test`,
|
|
2379
|
+
creation_schema: { type: `object` }
|
|
2380
|
+
}).spawn(typeName, `entity-1`).setTags({
|
|
2381
|
+
owner: `test-user`,
|
|
2382
|
+
priority: `high`
|
|
2383
|
+
}).expectTags({
|
|
2384
|
+
owner: `test-user`,
|
|
2385
|
+
priority: `high`
|
|
2386
|
+
}).run();
|
|
2387
|
+
});
|
|
2388
|
+
vitest.test.skip(`tag write rejects non-string values`, () => {
|
|
2389
|
+
const typeName = `tags-invalid-${Date.now()}`;
|
|
2390
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-invalid-sub`).registerType({
|
|
2391
|
+
name: typeName,
|
|
2392
|
+
description: `Type with string-only tags`,
|
|
2393
|
+
creation_schema: { type: `object` }
|
|
2394
|
+
}).spawn(typeName, `entity-1`).custom(async (ctx) => {
|
|
2395
|
+
const tagHeaders = { "content-type": `application/json` };
|
|
2396
|
+
if (ctx.currentWriteToken) tagHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
|
|
2397
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/owner`)}`, {
|
|
2398
|
+
method: `POST`,
|
|
2399
|
+
headers: tagHeaders,
|
|
2400
|
+
body: JSON.stringify({ value: 123 })
|
|
2401
|
+
});
|
|
2402
|
+
(0, vitest.expect)(res.status).toBe(400);
|
|
2403
|
+
}).run();
|
|
2404
|
+
});
|
|
2405
|
+
vitest.test.skip(`tag delete removes a key`, () => {
|
|
2406
|
+
const typeName = `tags-delete-${Date.now()}`;
|
|
2407
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-delete-sub`).registerType({
|
|
2408
|
+
name: typeName,
|
|
2409
|
+
description: `Type for tag delete test`,
|
|
2410
|
+
creation_schema: { type: `object` }
|
|
2411
|
+
}).spawn(typeName, `entity-1`).setTags({
|
|
2412
|
+
owner: `test-user`,
|
|
2413
|
+
priority: `high`
|
|
2414
|
+
}).custom(async (ctx) => {
|
|
2415
|
+
const tagHeaders = {};
|
|
2416
|
+
if (ctx.currentWriteToken) tagHeaders.authorization = `Bearer ${ctx.currentWriteToken}`;
|
|
2417
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/priority`)}`, {
|
|
2418
|
+
method: `DELETE`,
|
|
2419
|
+
headers: tagHeaders
|
|
2420
|
+
});
|
|
2421
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
2422
|
+
}).expectTags({ owner: `test-user` }).run();
|
|
2423
|
+
});
|
|
2424
|
+
vitest.test.skip(`tag writes merge by key`, () => {
|
|
2425
|
+
const typeName = `tags-merge-${Date.now()}`;
|
|
2426
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-merge-sub`).registerType({
|
|
2427
|
+
name: typeName,
|
|
2428
|
+
description: `Type for tag merge test`,
|
|
2429
|
+
creation_schema: { type: `object` }
|
|
2430
|
+
}).spawn(typeName, `entity-1`).setTags({
|
|
2431
|
+
key1: `value1`,
|
|
2432
|
+
key2: `value2`
|
|
2433
|
+
}).setTags({
|
|
2434
|
+
key2: `updated`,
|
|
2435
|
+
key3: `value3`
|
|
2436
|
+
}).expectTags({
|
|
2437
|
+
key1: `value1`,
|
|
2438
|
+
key2: `updated`,
|
|
2439
|
+
key3: `value3`
|
|
2440
|
+
}).run();
|
|
2441
|
+
});
|
|
2442
|
+
});
|
|
2443
|
+
(0, vitest.describe)(`Electric Agents Schema Evolution`, () => {
|
|
2444
|
+
(0, vitest.test)(`amend schemas adds new message types`, () => {
|
|
2445
|
+
const typeName = `amend-add-${Date.now()}`;
|
|
2446
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `amend-add-sub`).registerType({
|
|
2447
|
+
name: typeName,
|
|
2448
|
+
description: `Type for schema amendment`,
|
|
2449
|
+
creation_schema: { type: `object` },
|
|
2450
|
+
input_schemas: { query: {
|
|
2451
|
+
type: `object`,
|
|
2452
|
+
properties: { text: { type: `string` } },
|
|
2453
|
+
required: [`text`]
|
|
2454
|
+
} }
|
|
2455
|
+
}).amendSchemas(typeName, { input_schemas: { command: {
|
|
2456
|
+
type: `object`,
|
|
2457
|
+
properties: { action: { type: `string` } },
|
|
2458
|
+
required: [`action`]
|
|
2459
|
+
} } }).spawn(typeName, `entity-1`).send({ action: `do-it` }, {
|
|
2460
|
+
from: `user`,
|
|
2461
|
+
type: `command`
|
|
2462
|
+
}).expectWebhook().respondDone().run();
|
|
2463
|
+
});
|
|
2464
|
+
(0, vitest.test)(`amend schemas rejects modifying existing keys (C16)`, () => {
|
|
2465
|
+
const typeName = `amend-conflict-${Date.now()}`;
|
|
2466
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `amend-conflict-sub`).registerType({
|
|
2467
|
+
name: typeName,
|
|
2468
|
+
description: `Type for schema conflict test`,
|
|
2469
|
+
creation_schema: { type: `object` },
|
|
2470
|
+
input_schemas: { query: {
|
|
2471
|
+
type: `object`,
|
|
2472
|
+
properties: { text: { type: `string` } },
|
|
2473
|
+
required: [`text`]
|
|
2474
|
+
} }
|
|
2475
|
+
}).custom(async (ctx) => {
|
|
2476
|
+
const res = await fetch(`${ctx.baseUrl}/_electric/entity-types/${typeName}/schemas`, {
|
|
2477
|
+
method: `PATCH`,
|
|
2478
|
+
headers: { "content-type": `application/json` },
|
|
2479
|
+
body: JSON.stringify({ inbox_schemas: { query: {
|
|
2480
|
+
type: `object`,
|
|
2481
|
+
properties: { text: { type: `number` } }
|
|
2482
|
+
} } })
|
|
2483
|
+
});
|
|
2484
|
+
(0, vitest.expect)(res.status).toBe(409);
|
|
2485
|
+
}).run();
|
|
2486
|
+
});
|
|
2487
|
+
vitest.test.skip(`schema amendments apply to existing entities`, () => {
|
|
2488
|
+
const typeName = `rev-pin-${Date.now()}`;
|
|
2489
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `rev-pin-sub`).registerType({
|
|
2490
|
+
name: typeName,
|
|
2491
|
+
description: `Type for revision pinning test`,
|
|
2492
|
+
creation_schema: { type: `object` },
|
|
2493
|
+
input_schemas: { query: {
|
|
2494
|
+
type: `object`,
|
|
2495
|
+
properties: { text: { type: `string` } },
|
|
2496
|
+
required: [`text`]
|
|
2497
|
+
} }
|
|
2498
|
+
}).spawn(typeName, `entity-1`).custom(async (ctx) => {
|
|
2499
|
+
const res = await fetch(`${ctx.baseUrl}/_electric/entity-types/${typeName}/schemas`, {
|
|
2500
|
+
method: `PATCH`,
|
|
2501
|
+
headers: { "content-type": `application/json` },
|
|
2502
|
+
body: JSON.stringify({ inbox_schemas: { new_command: {
|
|
2503
|
+
type: `object`,
|
|
2504
|
+
properties: { action: { type: `string` } }
|
|
2505
|
+
} } })
|
|
2506
|
+
});
|
|
2507
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
2508
|
+
}).custom(async (ctx) => {
|
|
2509
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/send`)}`, {
|
|
2510
|
+
method: `POST`,
|
|
2511
|
+
headers: { "content-type": `application/json` },
|
|
2512
|
+
body: JSON.stringify({
|
|
2513
|
+
type: `new_command`,
|
|
2514
|
+
from: `user`,
|
|
2515
|
+
payload: { action: `test` }
|
|
2516
|
+
})
|
|
2517
|
+
});
|
|
2518
|
+
(0, vitest.expect)(res.status).toBe(204);
|
|
2519
|
+
}).run();
|
|
2520
|
+
});
|
|
2521
|
+
});
|
|
2522
|
+
(0, vitest.describe)(`Electric Agents Serve Endpoint`, () => {
|
|
2523
|
+
(0, vitest.test)(`register type via serve endpoint`, () => {
|
|
2524
|
+
const typeName = `serve-reg-${Date.now()}`;
|
|
2525
|
+
return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `serve-reg-sub`).registerTypeViaServe({
|
|
2526
|
+
name: typeName,
|
|
2527
|
+
description: `Type registered via serve endpoint`,
|
|
2528
|
+
creation_schema: { type: `object` }
|
|
2529
|
+
}).expectTypeExists(typeName).run();
|
|
2530
|
+
});
|
|
2531
|
+
(0, vitest.test)(`serve endpoint name mismatch rejected`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
|
|
2532
|
+
const receiver = new ServeEndpointReceiver();
|
|
2533
|
+
const manifest = {
|
|
2534
|
+
name: `foo`,
|
|
2535
|
+
description: `Manifest says foo`,
|
|
2536
|
+
creation_schema: { type: `object` }
|
|
2537
|
+
};
|
|
2538
|
+
await receiver.start(manifest);
|
|
2539
|
+
try {
|
|
2540
|
+
const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
|
|
2541
|
+
method: `POST`,
|
|
2542
|
+
headers: { "content-type": `application/json` },
|
|
2543
|
+
body: JSON.stringify({
|
|
2544
|
+
name: `bar`,
|
|
2545
|
+
serve_endpoint: receiver.url
|
|
2546
|
+
})
|
|
2547
|
+
});
|
|
2548
|
+
(0, vitest.expect)([
|
|
2549
|
+
400,
|
|
2550
|
+
409,
|
|
2551
|
+
422
|
|
2552
|
+
]).toContain(res.status);
|
|
2553
|
+
} finally {
|
|
2554
|
+
await receiver.stop();
|
|
2555
|
+
}
|
|
2556
|
+
}).skipInvariants().run());
|
|
2557
|
+
});
|
|
2558
|
+
(0, vitest.describe)(`Property-Based: Random Action Sequences`, () => {
|
|
2559
|
+
const actionArb = fast_check.oneof({
|
|
2560
|
+
weight: 15,
|
|
2561
|
+
arbitrary: fast_check.constant(`register_type`)
|
|
2562
|
+
}, {
|
|
2563
|
+
weight: 5,
|
|
2564
|
+
arbitrary: fast_check.constant(`delete_type`)
|
|
2565
|
+
}, {
|
|
2566
|
+
weight: 25,
|
|
2567
|
+
arbitrary: fast_check.constant(`spawn`)
|
|
2568
|
+
}, {
|
|
2569
|
+
weight: 20,
|
|
2570
|
+
arbitrary: fast_check.constant(`send`)
|
|
2571
|
+
}, {
|
|
2572
|
+
weight: 10,
|
|
2573
|
+
arbitrary: fast_check.constant(`kill`)
|
|
2574
|
+
}, {
|
|
2575
|
+
weight: 5,
|
|
2576
|
+
arbitrary: fast_check.constant(`check_status`)
|
|
2577
|
+
}, {
|
|
2578
|
+
weight: 5,
|
|
2579
|
+
arbitrary: fast_check.constant(`list`)
|
|
2580
|
+
});
|
|
2581
|
+
(0, vitest.test)(`random action sequences preserve safety invariants`, async () => {
|
|
2582
|
+
await fast_check.assert(fast_check.asyncProperty(fast_check.array(actionArb, {
|
|
2583
|
+
minLength: 3,
|
|
2584
|
+
maxLength: 12
|
|
2585
|
+
}), async (actions) => {
|
|
2586
|
+
const runId = `prop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2587
|
+
const baseUrl = config.baseUrl;
|
|
2588
|
+
const entityUrls = [];
|
|
2589
|
+
const registeredTypeNames = [];
|
|
2590
|
+
const scenario = electricAgents(baseUrl);
|
|
2591
|
+
let model = {
|
|
2592
|
+
entityTypes: [],
|
|
2593
|
+
entities: [],
|
|
2594
|
+
nextEntityNum: 0
|
|
2595
|
+
};
|
|
2596
|
+
let entityCounter = 0;
|
|
2597
|
+
for (const action of actions) {
|
|
2598
|
+
const valid = enabledElectricAgentsActions(model);
|
|
2599
|
+
if (!valid.includes(action)) continue;
|
|
2600
|
+
switch (action) {
|
|
2601
|
+
case `register_type`: {
|
|
2602
|
+
const typeNum = model.entityTypes.length;
|
|
2603
|
+
const typeName = `prop-type-${runId}-${typeNum}`;
|
|
2604
|
+
registeredTypeNames.push(typeName);
|
|
2605
|
+
scenario.subscription(`/${typeName}/**`, `prop-sub-${typeName}`);
|
|
2606
|
+
scenario.registerType({
|
|
2607
|
+
name: typeName,
|
|
2608
|
+
description: `Property-based test type ${typeNum}`,
|
|
2609
|
+
creation_schema: { type: `object` }
|
|
2610
|
+
});
|
|
2611
|
+
model = applyElectricAgentsAction(model, `register_type`);
|
|
2612
|
+
break;
|
|
2613
|
+
}
|
|
2614
|
+
case `delete_type`: {
|
|
2615
|
+
const runningModelTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
|
|
2616
|
+
const deletableIndices = model.entityTypes.map((t, i) => !runningModelTypeNames.has(t.name) ? i : -1).filter((i) => i >= 0);
|
|
2617
|
+
if (deletableIndices.length === 0) break;
|
|
2618
|
+
const deleteIdx = deletableIndices[0];
|
|
2619
|
+
scenario.deleteType(registeredTypeNames[deleteIdx]);
|
|
2620
|
+
registeredTypeNames.splice(deleteIdx, 1);
|
|
2621
|
+
model = applyElectricAgentsAction(model, `delete_type`);
|
|
2622
|
+
break;
|
|
2623
|
+
}
|
|
2624
|
+
case `spawn`: {
|
|
2625
|
+
if (registeredTypeNames.length === 0) break;
|
|
2626
|
+
const typeName = registeredTypeNames[0];
|
|
2627
|
+
const instanceId = `entity-${entityCounter++}`;
|
|
2628
|
+
scenario.spawn(typeName, instanceId);
|
|
2629
|
+
scenario.custom(async (ctx) => {
|
|
2630
|
+
entityUrls.push(ctx.currentEntityUrl);
|
|
2631
|
+
});
|
|
2632
|
+
model = applyElectricAgentsAction(model, `spawn`);
|
|
2633
|
+
break;
|
|
2634
|
+
}
|
|
2635
|
+
case `send`: {
|
|
2636
|
+
const runningIdxs = model.entities.map((e, i) => e.status === `running` ? i : -1).filter((i) => i >= 0);
|
|
2637
|
+
if (runningIdxs.length === 0) break;
|
|
2638
|
+
const targetIdx = runningIdxs[Math.floor(Math.random() * runningIdxs.length)];
|
|
2639
|
+
scenario.custom(async (ctx) => {
|
|
2640
|
+
const url = entityUrls[targetIdx];
|
|
2641
|
+
if (!url) return;
|
|
2642
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `${url}/send`, {
|
|
2643
|
+
method: `POST`,
|
|
2644
|
+
body: JSON.stringify({
|
|
2645
|
+
from: `prop-test`,
|
|
2646
|
+
payload: {
|
|
2647
|
+
action: `prop-send`,
|
|
2648
|
+
targetIdx
|
|
2649
|
+
}
|
|
2650
|
+
})
|
|
2651
|
+
});
|
|
2652
|
+
(0, vitest.expect)(res.status).toBe(204);
|
|
2653
|
+
ctx.history.push({
|
|
2654
|
+
type: `message_sent`,
|
|
2655
|
+
entityUrl: url,
|
|
2656
|
+
payload: {
|
|
2657
|
+
action: `prop-send`,
|
|
2658
|
+
targetIdx
|
|
2659
|
+
},
|
|
2660
|
+
from: `prop-test`
|
|
2661
|
+
});
|
|
2662
|
+
});
|
|
2663
|
+
scenario.expectWebhook();
|
|
2664
|
+
scenario.respondDone();
|
|
2665
|
+
model = applyElectricAgentsAction(model, `send`, targetIdx);
|
|
2666
|
+
break;
|
|
2667
|
+
}
|
|
2668
|
+
case `kill`: {
|
|
2669
|
+
const runningIdxs = model.entities.map((e, i) => e.status === `running` ? i : -1).filter((i) => i >= 0);
|
|
2670
|
+
if (runningIdxs.length === 0) break;
|
|
2671
|
+
const targetIdx = runningIdxs[Math.floor(Math.random() * runningIdxs.length)];
|
|
2672
|
+
scenario.custom(async (ctx) => {
|
|
2673
|
+
const url = entityUrls[targetIdx];
|
|
2674
|
+
if (!url) return;
|
|
2675
|
+
const res = await electricAgentsFetch(ctx.baseUrl, url, { method: `DELETE` });
|
|
2676
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
2677
|
+
ctx.history.push({
|
|
2678
|
+
type: `entity_killed`,
|
|
2679
|
+
entityUrl: url
|
|
2680
|
+
});
|
|
2681
|
+
});
|
|
2682
|
+
model = applyElectricAgentsAction(model, `kill`, targetIdx);
|
|
2683
|
+
break;
|
|
2684
|
+
}
|
|
2685
|
+
case `check_status`: {
|
|
2686
|
+
const anyIdx = model.entities.length > 0 ? 0 : -1;
|
|
2687
|
+
if (anyIdx < 0) break;
|
|
2688
|
+
const expectedStatus = model.entities[anyIdx].status;
|
|
2689
|
+
scenario.custom(async (ctx) => {
|
|
2690
|
+
const url = entityUrls[anyIdx];
|
|
2691
|
+
if (!url) return;
|
|
2692
|
+
const res = await electricAgentsFetch(ctx.baseUrl, url);
|
|
2693
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
2694
|
+
const entity = await res.json();
|
|
2695
|
+
if (expectedStatus === `running`) (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
|
|
2696
|
+
else (0, vitest.expect)(entity.status).toBe(expectedStatus);
|
|
2697
|
+
ctx.history.push({
|
|
2698
|
+
type: `entity_status_checked`,
|
|
2699
|
+
entityUrl: url,
|
|
2700
|
+
status: entity.status
|
|
2701
|
+
});
|
|
2702
|
+
});
|
|
2703
|
+
model = applyElectricAgentsAction(model, `check_status`);
|
|
2704
|
+
break;
|
|
2705
|
+
}
|
|
2706
|
+
case `list`: {
|
|
2707
|
+
scenario.list();
|
|
2708
|
+
model = applyElectricAgentsAction(model, `list`);
|
|
2709
|
+
break;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const history = await scenario.run();
|
|
2714
|
+
checkInvariants(history);
|
|
2715
|
+
}), {
|
|
2716
|
+
numRuns: 15,
|
|
2717
|
+
endOnFailure: true
|
|
2718
|
+
});
|
|
2719
|
+
}, 3e4);
|
|
2720
|
+
});
|
|
2721
|
+
vitest.describe.skip(`Electric Agents - StreamDB Materialization`, () => {
|
|
2722
|
+
(0, vitest.test)(`State Protocol events materialize with correct structure`, () => electricAgents(config.baseUrl).subscription(`/mat-test-agent/**`, `mat-test-sub`).registerType({
|
|
2723
|
+
name: `mat-test-agent`,
|
|
2724
|
+
description: `Test materialization`
|
|
2725
|
+
}).spawn(`mat-test-agent`, `mat-1`).writeStateProtocol({
|
|
2726
|
+
type: `run`,
|
|
2727
|
+
key: `run-0`,
|
|
2728
|
+
value: { status: `started` },
|
|
2729
|
+
headers: { operation: `insert` }
|
|
2730
|
+
}).writeStateProtocol({
|
|
2731
|
+
type: `step`,
|
|
2732
|
+
key: `step-0`,
|
|
2733
|
+
value: {
|
|
2734
|
+
status: `started`,
|
|
2735
|
+
step_number: 1
|
|
2736
|
+
},
|
|
2737
|
+
headers: {
|
|
2738
|
+
operation: `insert`,
|
|
2739
|
+
model_provider: `anthropic`,
|
|
2740
|
+
model_id: `claude-sonnet-4-5`
|
|
2741
|
+
}
|
|
2742
|
+
}).writeStateProtocol({
|
|
2743
|
+
type: `text`,
|
|
2744
|
+
key: `msg-0`,
|
|
2745
|
+
value: { status: `streaming` },
|
|
2746
|
+
headers: { operation: `insert` }
|
|
2747
|
+
}).writeStateProtocol({
|
|
2748
|
+
type: `text_delta`,
|
|
2749
|
+
key: `msg-0:0`,
|
|
2750
|
+
value: {
|
|
2751
|
+
delta: `Hello `,
|
|
2752
|
+
text_id: `msg-0`
|
|
2753
|
+
},
|
|
2754
|
+
headers: { operation: `insert` }
|
|
2755
|
+
}).writeStateProtocol({
|
|
2756
|
+
type: `text_delta`,
|
|
2757
|
+
key: `msg-0:1`,
|
|
2758
|
+
value: {
|
|
2759
|
+
delta: `world`,
|
|
2760
|
+
text_id: `msg-0`
|
|
2761
|
+
},
|
|
2762
|
+
headers: { operation: `insert` }
|
|
2763
|
+
}).writeStateProtocol({
|
|
2764
|
+
type: `text`,
|
|
2765
|
+
key: `msg-0`,
|
|
2766
|
+
value: { status: `completed` },
|
|
2767
|
+
headers: { operation: `update` }
|
|
2768
|
+
}).writeStateProtocol({
|
|
2769
|
+
type: `step`,
|
|
2770
|
+
key: `step-0`,
|
|
2771
|
+
value: {
|
|
2772
|
+
status: `completed`,
|
|
2773
|
+
step_number: 1,
|
|
2774
|
+
finish_reason: `stop`
|
|
2775
|
+
},
|
|
2776
|
+
headers: {
|
|
2777
|
+
operation: `update`,
|
|
2778
|
+
duration_ms: 1500,
|
|
2779
|
+
token_input: 10,
|
|
2780
|
+
token_output: 5
|
|
2781
|
+
}
|
|
2782
|
+
}).writeStateProtocol({
|
|
2783
|
+
type: `run`,
|
|
2784
|
+
key: `run-0`,
|
|
2785
|
+
value: {
|
|
2786
|
+
status: `completed`,
|
|
2787
|
+
finish_reason: `stop`
|
|
2788
|
+
},
|
|
2789
|
+
headers: {
|
|
2790
|
+
operation: `update`,
|
|
2791
|
+
duration_ms: 2e3
|
|
2792
|
+
}
|
|
2793
|
+
}).readStream().custom(async (ctx) => {
|
|
2794
|
+
const events = ctx.lastStreamMessages;
|
|
2795
|
+
const spEvents = events.filter((e) => e.type && e.key && e.headers);
|
|
2796
|
+
const runEvents = spEvents.filter((e) => e.type === `run`);
|
|
2797
|
+
(0, vitest.expect)(runEvents.length).toBe(2);
|
|
2798
|
+
(0, vitest.expect)(runEvents[0].headers.operation).toBe(`insert`);
|
|
2799
|
+
(0, vitest.expect)(runEvents[1].headers.operation).toBe(`update`);
|
|
2800
|
+
const stepEvents = spEvents.filter((e) => e.type === `step`);
|
|
2801
|
+
(0, vitest.expect)(stepEvents.length).toBe(2);
|
|
2802
|
+
(0, vitest.expect)(stepEvents[0].headers.operation).toBe(`insert`);
|
|
2803
|
+
(0, vitest.expect)(stepEvents[1].headers.operation).toBe(`update`);
|
|
2804
|
+
const textEvents = spEvents.filter((e) => e.type === `text`);
|
|
2805
|
+
(0, vitest.expect)(textEvents.length).toBe(2);
|
|
2806
|
+
(0, vitest.expect)(textEvents[0].headers.operation).toBe(`insert`);
|
|
2807
|
+
(0, vitest.expect)(textEvents[1].headers.operation).toBe(`update`);
|
|
2808
|
+
const deltas = spEvents.filter((e) => e.type === `text_delta`);
|
|
2809
|
+
(0, vitest.expect)(deltas.length).toBe(2);
|
|
2810
|
+
(0, vitest.expect)(deltas[0].key).toBe(`msg-0:0`);
|
|
2811
|
+
(0, vitest.expect)(deltas[1].key).toBe(`msg-0:1`);
|
|
2812
|
+
const completedText = textEvents.find((e) => e.headers.operation === `update`);
|
|
2813
|
+
(0, vitest.expect)(completedText.value.status).toBe(`completed`);
|
|
2814
|
+
checkStateProtocolInvariants(spEvents);
|
|
2815
|
+
}).kill().run());
|
|
2816
|
+
(0, vitest.test)(`tool call state machine follows started → executing → completed`, () => electricAgents(config.baseUrl).subscription(`/tc-mat-agent/**`, `tc-mat-sub`).registerType({
|
|
2817
|
+
name: `tc-mat-agent`,
|
|
2818
|
+
description: `Test tool call materialization`
|
|
2819
|
+
}).spawn(`tc-mat-agent`, `tc-mat-1`).writeStateProtocol({
|
|
2820
|
+
type: `run`,
|
|
2821
|
+
key: `run-0`,
|
|
2822
|
+
value: { status: `started` },
|
|
2823
|
+
headers: { operation: `insert` }
|
|
2824
|
+
}).writeStateProtocol({
|
|
2825
|
+
type: `step`,
|
|
2826
|
+
key: `step-0`,
|
|
2827
|
+
value: {
|
|
2828
|
+
status: `started`,
|
|
2829
|
+
step_number: 1
|
|
2830
|
+
},
|
|
2831
|
+
headers: { operation: `insert` }
|
|
2832
|
+
}).writeStateProtocol({
|
|
2833
|
+
type: `tool_call`,
|
|
2834
|
+
key: `tc-0`,
|
|
2835
|
+
value: {
|
|
2836
|
+
tool_name: `web_search`,
|
|
2837
|
+
status: `started`
|
|
2838
|
+
},
|
|
2839
|
+
headers: { operation: `insert` }
|
|
2840
|
+
}).writeStateProtocol({
|
|
2841
|
+
type: `tool_call`,
|
|
2842
|
+
key: `tc-0`,
|
|
2843
|
+
value: {
|
|
2844
|
+
tool_name: `web_search`,
|
|
2845
|
+
status: `executing`
|
|
2846
|
+
},
|
|
2847
|
+
headers: { operation: `update` }
|
|
2848
|
+
}).writeStateProtocol({
|
|
2849
|
+
type: `tool_call`,
|
|
2850
|
+
key: `tc-0`,
|
|
2851
|
+
value: {
|
|
2852
|
+
tool_name: `web_search`,
|
|
2853
|
+
result: `Search results here`,
|
|
2854
|
+
status: `completed`
|
|
2855
|
+
},
|
|
2856
|
+
headers: {
|
|
2857
|
+
operation: `update`,
|
|
2858
|
+
duration_ms: 500
|
|
2859
|
+
}
|
|
2860
|
+
}).readStream().custom(async (ctx) => {
|
|
2861
|
+
const events = ctx.lastStreamMessages;
|
|
2862
|
+
const tcEvents = events.filter((e) => e.type === `tool_call`);
|
|
2863
|
+
(0, vitest.expect)(tcEvents.length).toBe(3);
|
|
2864
|
+
(0, vitest.expect)(tcEvents[0].value.status).toBe(`started`);
|
|
2865
|
+
(0, vitest.expect)(tcEvents[0].headers.operation).toBe(`insert`);
|
|
2866
|
+
(0, vitest.expect)(tcEvents[1].value.status).toBe(`executing`);
|
|
2867
|
+
(0, vitest.expect)(tcEvents[1].headers.operation).toBe(`update`);
|
|
2868
|
+
(0, vitest.expect)(tcEvents[2].value.status).toBe(`completed`);
|
|
2869
|
+
(0, vitest.expect)(tcEvents[2].headers.operation).toBe(`update`);
|
|
2870
|
+
checkStateProtocolInvariants(events.filter((e) => e.type && e.key && e.headers));
|
|
2871
|
+
}).kill().run());
|
|
2872
|
+
(0, vitest.test)(`insert creates new entries, update modifies existing entries by key`, () => electricAgents(config.baseUrl).subscription(`/upsert-agent/**`, `upsert-sub`).registerType({
|
|
2873
|
+
name: `upsert-agent`,
|
|
2874
|
+
description: `Test insert/update semantics`
|
|
2875
|
+
}).spawn(`upsert-agent`, `upsert-1`).writeStateProtocol({
|
|
2876
|
+
type: `text`,
|
|
2877
|
+
key: `msg-0`,
|
|
2878
|
+
value: { status: `streaming` },
|
|
2879
|
+
headers: { operation: `insert` }
|
|
2880
|
+
}).writeStateProtocol({
|
|
2881
|
+
type: `text`,
|
|
2882
|
+
key: `msg-0`,
|
|
2883
|
+
value: { status: `completed` },
|
|
2884
|
+
headers: { operation: `update` }
|
|
2885
|
+
}).writeStateProtocol({
|
|
2886
|
+
type: `text`,
|
|
2887
|
+
key: `msg-1`,
|
|
2888
|
+
value: { status: `streaming` },
|
|
2889
|
+
headers: { operation: `insert` }
|
|
2890
|
+
}).readStream().custom(async (ctx) => {
|
|
2891
|
+
const events = ctx.lastStreamMessages;
|
|
2892
|
+
const textEvents = events.filter((e) => e.type === `text`);
|
|
2893
|
+
(0, vitest.expect)(textEvents.length).toBe(3);
|
|
2894
|
+
const keys = textEvents.map((e) => e.key);
|
|
2895
|
+
(0, vitest.expect)(keys).toContain(`msg-0`);
|
|
2896
|
+
(0, vitest.expect)(keys).toContain(`msg-1`);
|
|
2897
|
+
}).kill().run());
|
|
2898
|
+
});
|
|
2899
|
+
(0, vitest.describe)(`Electric Agents - Tool Tests`, () => {
|
|
2900
|
+
/** Extract text from the first content block of a tool result. */
|
|
2901
|
+
function firstText(result) {
|
|
2902
|
+
const block = result.content[0];
|
|
2903
|
+
return block?.type === `text` && block.text ? block.text : ``;
|
|
2904
|
+
}
|
|
2905
|
+
(0, vitest.test)(`bash tool captures stdout and stderr`, async () => {
|
|
2906
|
+
const { createBashTool } = await import(`../../agents-runtime/src/tools`);
|
|
2907
|
+
const tool = createBashTool(`/tmp`);
|
|
2908
|
+
const result = await tool.execute(`test-tc`, { command: `echo "hello" && echo "error" >&2` });
|
|
2909
|
+
(0, vitest.expect)(firstText(result)).toContain(`hello`);
|
|
2910
|
+
(0, vitest.expect)(firstText(result)).toContain(`error`);
|
|
2911
|
+
(0, vitest.expect)(result.details.exitCode).toBe(0);
|
|
2912
|
+
});
|
|
2913
|
+
(0, vitest.test)(`bash tool enforces timeout`, async () => {
|
|
2914
|
+
const { createBashTool } = await import(`../../agents-runtime/src/tools`);
|
|
2915
|
+
const tool = createBashTool(`/tmp`);
|
|
2916
|
+
const result = await tool.execute(`test-tc`, { command: `sleep 60` });
|
|
2917
|
+
(0, vitest.expect)(result.details.timedOut).toBe(true);
|
|
2918
|
+
}, 35e3);
|
|
2919
|
+
(0, vitest.test)(`read_file rejects paths outside working directory`, async () => {
|
|
2920
|
+
const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
|
|
2921
|
+
const tool = createReadFileTool(`/tmp/test-workdir`);
|
|
2922
|
+
const result = await tool.execute(`test-tc`, { path: `../../etc/passwd` });
|
|
2923
|
+
(0, vitest.expect)(firstText(result)).toContain(`outside the working directory`);
|
|
2924
|
+
});
|
|
2925
|
+
(0, vitest.test)(`read_file rejects binary files`, async () => {
|
|
2926
|
+
const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
|
|
2927
|
+
const fs = await import(`node:fs/promises`);
|
|
2928
|
+
const path = await import(`node:path`);
|
|
2929
|
+
const dir = `/tmp/test-binary-${Date.now()}`;
|
|
2930
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2931
|
+
const binPath = path.join(dir, `test.bin`);
|
|
2932
|
+
await fs.writeFile(binPath, Buffer.from([
|
|
2933
|
+
0,
|
|
2934
|
+
1,
|
|
2935
|
+
2,
|
|
2936
|
+
255
|
|
2937
|
+
]));
|
|
2938
|
+
const tool = createReadFileTool(dir);
|
|
2939
|
+
const result = await tool.execute(`test-tc`, { path: `test.bin` });
|
|
2940
|
+
(0, vitest.expect)(firstText(result)).toContain(`binary file`);
|
|
2941
|
+
await fs.rm(dir, { recursive: true });
|
|
2942
|
+
});
|
|
2943
|
+
(0, vitest.test)(`read_file rejects oversized files`, async () => {
|
|
2944
|
+
const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
|
|
2945
|
+
const fs = await import(`node:fs/promises`);
|
|
2946
|
+
const path = await import(`node:path`);
|
|
2947
|
+
const dir = `/tmp/test-size-${Date.now()}`;
|
|
2948
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2949
|
+
const bigPath = path.join(dir, `big.txt`);
|
|
2950
|
+
await fs.writeFile(bigPath, `x`.repeat(600 * 1024));
|
|
2951
|
+
const tool = createReadFileTool(dir);
|
|
2952
|
+
const result = await tool.execute(`test-tc`, { path: `big.txt` });
|
|
2953
|
+
(0, vitest.expect)(firstText(result)).toContain(`too large`);
|
|
2954
|
+
await fs.rm(dir, { recursive: true });
|
|
2955
|
+
});
|
|
2956
|
+
(0, vitest.test)(`web_search tool has correct interface`, async () => {
|
|
2957
|
+
const { braveSearchTool } = await import(`../../agents-runtime/src/tools`);
|
|
2958
|
+
(0, vitest.expect)(braveSearchTool.name).toBe(`web_search`);
|
|
2959
|
+
(0, vitest.expect)(typeof braveSearchTool.execute).toBe(`function`);
|
|
2960
|
+
});
|
|
2961
|
+
(0, vitest.test)(`fetch_url tool has correct interface`, async () => {
|
|
2962
|
+
const { fetchUrlTool } = await import(`../../agents-runtime/src/tools`);
|
|
2963
|
+
(0, vitest.expect)(fetchUrlTool.name).toBe(`fetch_url`);
|
|
2964
|
+
(0, vitest.expect)(typeof fetchUrlTool.execute).toBe(`function`);
|
|
2965
|
+
});
|
|
2966
|
+
});
|
|
2967
|
+
(0, vitest.describe)(`Electric Agents Send — State Protocol Format`, () => {
|
|
2968
|
+
(0, vitest.test)(`send() writes events in State Protocol format`, () => electricAgents(config.baseUrl).subscription(`/sp-send-worker/**`, `sp-send-sub`).registerType({
|
|
2969
|
+
name: `sp-send-worker`,
|
|
2970
|
+
description: `Test entity type for SP send format`,
|
|
2971
|
+
creation_schema: { type: `object` }
|
|
2972
|
+
}).spawn(`sp-send-worker`, `entity-1`).send({ text: `hello world` }, { from: `user-1` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
2973
|
+
const events = ctx.lastStreamMessages;
|
|
2974
|
+
const msgEvent = events.find((e) => e.type === `message_received`);
|
|
2975
|
+
(0, vitest.expect)(msgEvent.type).toBe(`message_received`);
|
|
2976
|
+
(0, vitest.expect)(msgEvent.key).toBeDefined();
|
|
2977
|
+
(0, vitest.expect)(msgEvent.value?.from).toBe(`user-1`);
|
|
2978
|
+
(0, vitest.expect)(msgEvent.value?.payload).toEqual({ text: `hello world` });
|
|
2979
|
+
(0, vitest.expect)(msgEvent.headers).toBeDefined();
|
|
2980
|
+
}).run());
|
|
2981
|
+
(0, vitest.test)(`send() events pass State Protocol invariant checks`, () => electricAgents(config.baseUrl).subscription(`/sp-inv-worker/**`, `sp-inv-sub`).registerType({
|
|
2982
|
+
name: `sp-inv-worker`,
|
|
2983
|
+
description: `Test entity type for SP invariant checks`,
|
|
2984
|
+
creation_schema: { type: `object` }
|
|
2985
|
+
}).spawn(`sp-inv-worker`, `entity-1`).send({ text: `first` }, { from: `tester` }).expectWebhook().respondDone().send({ text: `second` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
2986
|
+
const events = ctx.lastStreamMessages;
|
|
2987
|
+
const messageEvents = events.filter((ev) => ev.type === `message_received`);
|
|
2988
|
+
(0, vitest.expect)(messageEvents.length).toBe(2);
|
|
2989
|
+
checkStateProtocolInvariants(events);
|
|
2990
|
+
}).run());
|
|
2991
|
+
(0, vitest.test)(`multiple sends produce unique sequential keys`, () => {
|
|
2992
|
+
const id = Date.now();
|
|
2993
|
+
return electricAgents(config.baseUrl).subscription(`/sp-keys-agent-${id}/**`, `sp-keys-sub-${id}`).registerType({
|
|
2994
|
+
name: `sp-keys-agent-${id}`,
|
|
2995
|
+
description: `Test entity type for send key uniqueness`,
|
|
2996
|
+
creation_schema: { type: `object` }
|
|
2997
|
+
}).spawn(`sp-keys-agent-${id}`, `entity-1`).send({ seq: 1 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 2 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 3 }, { from: `test` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
2998
|
+
const events = ctx.lastStreamMessages.filter((e) => e.type === `message_received`);
|
|
2999
|
+
(0, vitest.expect)(events.length).toBe(3);
|
|
3000
|
+
for (let i = 0; i < 3; i++) (0, vitest.expect)(events[i].value?.payload).toEqual({ seq: i + 1 });
|
|
3001
|
+
}).run();
|
|
3002
|
+
});
|
|
3003
|
+
});
|
|
3004
|
+
(0, vitest.describe)(`Electric Agents findLastUserMessage — State Protocol`, () => {
|
|
3005
|
+
(0, vitest.test)(`send event is parseable by webhook handler`, () => {
|
|
3006
|
+
const id = Date.now();
|
|
3007
|
+
return electricAgents(config.baseUrl).subscription(`/flum-agent-${id}/**`, `flum-sub-${id}`).registerType({
|
|
3008
|
+
name: `flum-agent-${id}`,
|
|
3009
|
+
description: `Test send event format for webhook parsing`,
|
|
3010
|
+
creation_schema: { type: `object` }
|
|
3011
|
+
}).spawn(`flum-agent-${id}`, `entity-1`).send({ text: `test message for parsing` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
3012
|
+
const events = ctx.lastStreamMessages;
|
|
3013
|
+
const msgEvent = events.find((e) => e.type === `message_received`);
|
|
3014
|
+
(0, vitest.expect)(msgEvent).toBeDefined();
|
|
3015
|
+
(0, vitest.expect)(msgEvent.value?.payload).toBeDefined();
|
|
3016
|
+
const payload = msgEvent.value.payload;
|
|
3017
|
+
(0, vitest.expect)(payload.text).toBe(`test message for parsing`);
|
|
3018
|
+
}).run();
|
|
3019
|
+
});
|
|
3020
|
+
(0, vitest.test)(`stream events after send are all State Protocol format`, () => {
|
|
3021
|
+
const id = Date.now();
|
|
3022
|
+
return electricAgents(config.baseUrl).subscription(`/flum-all-agent-${id}/**`, `flum-all-sub-${id}`).registerType({
|
|
3023
|
+
name: `flum-all-agent-${id}`,
|
|
3024
|
+
description: `Test all events are SP format`,
|
|
3025
|
+
creation_schema: { type: `object` }
|
|
3026
|
+
}).spawn(`flum-all-agent-${id}`, `entity-1`).send({ text: `verify all SP` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
|
|
3027
|
+
const events = ctx.lastStreamMessages;
|
|
3028
|
+
(0, vitest.expect)(events.length).toBeGreaterThanOrEqual(1);
|
|
3029
|
+
const hasMsg = events.some((e) => e.type === `message_received`);
|
|
3030
|
+
(0, vitest.expect)(hasMsg).toBe(true);
|
|
3031
|
+
for (const ev of events) {
|
|
3032
|
+
(0, vitest.expect)(ev.type, `every event must have type`).toBeDefined();
|
|
3033
|
+
(0, vitest.expect)(ev.key, `SP events must have key`).toBeDefined();
|
|
3034
|
+
(0, vitest.expect)(ev.headers, `SP events must have headers`).toBeDefined();
|
|
3035
|
+
}
|
|
3036
|
+
}).run();
|
|
3037
|
+
});
|
|
3038
|
+
});
|
|
3039
|
+
(0, vitest.describe)(`Error Paths`, () => {
|
|
3040
|
+
(0, vitest.test)(`kill nonexistent entity with registered type returns 404`, async () => {
|
|
3041
|
+
const typeName = `kill-miss-${Date.now()}`;
|
|
3042
|
+
await electricAgentsFetch(config.baseUrl, `/_electric/entity-types`, {
|
|
3043
|
+
method: `POST`,
|
|
3044
|
+
body: JSON.stringify({
|
|
3045
|
+
name: typeName,
|
|
3046
|
+
description: `test`
|
|
3047
|
+
})
|
|
3048
|
+
});
|
|
3049
|
+
const res = await electricAgentsFetch(config.baseUrl, `/${typeName}/nonexistent-id-12345`, { method: `DELETE` });
|
|
3050
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
3051
|
+
const body = await res.json();
|
|
3052
|
+
(0, vitest.expect)(body.error.code).toBe(`NOT_FOUND`);
|
|
3053
|
+
});
|
|
3054
|
+
vitest.test.skip(`kill already-stopped entity is idempotent`, () => {
|
|
3055
|
+
const id = Date.now();
|
|
3056
|
+
return electricAgents(config.baseUrl).subscription(`/kill-stopped-agent-${id}/**`, `kill-stopped-sub-${id}`).registerType({
|
|
3057
|
+
name: `kill-stopped-agent-${id}`,
|
|
3058
|
+
description: `Test killing stopped entity`,
|
|
3059
|
+
creation_schema: { type: `object` }
|
|
3060
|
+
}).spawn(`kill-stopped-agent-${id}`, `entity-1`).kill().custom(async (ctx) => {
|
|
3061
|
+
const res = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl, { method: `DELETE` });
|
|
3062
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3063
|
+
}).run();
|
|
3064
|
+
});
|
|
3065
|
+
(0, vitest.test)(`send without payload is rejected`, async () => {
|
|
3066
|
+
const id = Date.now();
|
|
3067
|
+
await electricAgents(config.baseUrl).subscription(`/no-payload-agent-${id}/**`, `no-payload-sub-${id}`).registerType({
|
|
3068
|
+
name: `no-payload-agent-${id}`,
|
|
3069
|
+
description: `Test send without payload`,
|
|
3070
|
+
creation_schema: { type: `object` }
|
|
3071
|
+
}).spawn(`no-payload-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3072
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
|
|
3073
|
+
method: `POST`,
|
|
3074
|
+
body: JSON.stringify({ from: `tester` })
|
|
3075
|
+
});
|
|
3076
|
+
(0, vitest.expect)(res.status).toBe(400);
|
|
3077
|
+
const body = await res.json();
|
|
3078
|
+
(0, vitest.expect)(body.error.code).toBe(`INVALID_REQUEST`);
|
|
3079
|
+
}).run();
|
|
3080
|
+
});
|
|
3081
|
+
(0, vitest.test)(`delete nonexistent entity type returns 404`, async () => {
|
|
3082
|
+
const res = await electricAgentsFetch(config.baseUrl, `/_electric/entity-types/nonexistent-type-xyz`, { method: `DELETE` });
|
|
3083
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
3084
|
+
});
|
|
3085
|
+
(0, vitest.test)(`delete entity type goes through state stream`, () => {
|
|
3086
|
+
const id = Date.now();
|
|
3087
|
+
return electricAgents(config.baseUrl).subscription(`/del-state-agent-${id}/**`, `del-state-sub-${id}`).registerType({
|
|
3088
|
+
name: `del-state-agent-${id}`,
|
|
3089
|
+
description: `Test delete via state stream`,
|
|
3090
|
+
creation_schema: { type: `object` }
|
|
3091
|
+
}).deleteType(`del-state-agent-${id}`).custom(async (ctx) => {
|
|
3092
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/del-state-agent-${id}`);
|
|
3093
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
3094
|
+
}).run();
|
|
3095
|
+
});
|
|
3096
|
+
(0, vitest.test)(`tag update on stopped entity is rejected without claim`, () => {
|
|
3097
|
+
const id = Date.now();
|
|
3098
|
+
return electricAgents(config.baseUrl).subscription(`/meta-stopped-agent-${id}/**`, `meta-stopped-sub-${id}`).registerType({
|
|
3099
|
+
name: `meta-stopped-agent-${id}`,
|
|
3100
|
+
description: `Test tag write on stopped entity`,
|
|
3101
|
+
creation_schema: { type: `object` }
|
|
3102
|
+
}).spawn(`meta-stopped-agent-${id}`, `entity-1`).kill().custom(async (ctx) => {
|
|
3103
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key`, {
|
|
3104
|
+
method: `POST`,
|
|
3105
|
+
body: JSON.stringify({ value: `value` })
|
|
3106
|
+
});
|
|
3107
|
+
(0, vitest.expect)(res.status).toBe(401);
|
|
3108
|
+
}).run();
|
|
3109
|
+
});
|
|
3110
|
+
});
|
|
3111
|
+
(0, vitest.describe)(`Type Revision Edge Cases`, () => {
|
|
3112
|
+
(0, vitest.test)(`existing entities accept schemas added across multiple amendments`, () => {
|
|
3113
|
+
const id = Date.now();
|
|
3114
|
+
return electricAgents(config.baseUrl).subscription(`/rev-multi-agent-${id}/**`, `rev-multi-sub-${id}`).registerType({
|
|
3115
|
+
name: `rev-multi-agent-${id}`,
|
|
3116
|
+
description: `Test multi-revision pinning`,
|
|
3117
|
+
creation_schema: { type: `object` },
|
|
3118
|
+
input_schemas: { greet: {
|
|
3119
|
+
type: `object`,
|
|
3120
|
+
properties: { name: { type: `string` } },
|
|
3121
|
+
required: [`name`]
|
|
3122
|
+
} }
|
|
3123
|
+
}).spawn(`rev-multi-agent-${id}`, `entity-rev1`).custom(async (ctx) => {
|
|
3124
|
+
const rev1EntityUrl = ctx.currentEntityUrl;
|
|
3125
|
+
await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/rev-multi-agent-${id}/schemas`, {
|
|
3126
|
+
method: `PATCH`,
|
|
3127
|
+
body: JSON.stringify({ inbox_schemas: { farewell: {
|
|
3128
|
+
type: `object`,
|
|
3129
|
+
properties: { reason: { type: `string` } },
|
|
3130
|
+
required: [`reason`]
|
|
3131
|
+
} } })
|
|
3132
|
+
});
|
|
3133
|
+
await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/rev-multi-agent-${id}/schemas`, {
|
|
3134
|
+
method: `PATCH`,
|
|
3135
|
+
body: JSON.stringify({ inbox_schemas: { status: {
|
|
3136
|
+
type: `object`,
|
|
3137
|
+
properties: { active: { type: `boolean` } }
|
|
3138
|
+
} } })
|
|
3139
|
+
});
|
|
3140
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `${rev1EntityUrl}/send`, {
|
|
3141
|
+
method: `POST`,
|
|
3142
|
+
body: JSON.stringify({
|
|
3143
|
+
from: `tester`,
|
|
3144
|
+
payload: { reason: `bye` },
|
|
3145
|
+
type: `farewell`
|
|
3146
|
+
})
|
|
3147
|
+
});
|
|
3148
|
+
(0, vitest.expect)(res.status).toBe(204);
|
|
3149
|
+
}).run();
|
|
3150
|
+
});
|
|
3151
|
+
(0, vitest.test)(`amend schemas on deleted type returns 404`, async () => {
|
|
3152
|
+
const id = Date.now();
|
|
3153
|
+
await electricAgents(config.baseUrl).subscription(`/amend-del-agent-${id}/**`, `amend-del-sub-${id}`).registerType({
|
|
3154
|
+
name: `amend-del-agent-${id}`,
|
|
3155
|
+
description: `Test amend on deleted type`,
|
|
3156
|
+
creation_schema: { type: `object` }
|
|
3157
|
+
}).deleteType(`amend-del-agent-${id}`).custom(async (ctx) => {
|
|
3158
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/amend-del-agent-${id}/schemas`, {
|
|
3159
|
+
method: `PATCH`,
|
|
3160
|
+
body: JSON.stringify({ input_schemas: { msg: { type: `object` } } })
|
|
3161
|
+
});
|
|
3162
|
+
(0, vitest.expect)(res.status).toBe(404);
|
|
3163
|
+
}).run();
|
|
3164
|
+
});
|
|
3165
|
+
});
|
|
3166
|
+
(0, vitest.describe)(`Concurrent Operations`, () => {
|
|
3167
|
+
(0, vitest.test)(`sequential tag updates accumulate`, async () => {
|
|
3168
|
+
const id = Date.now();
|
|
3169
|
+
await electricAgents(config.baseUrl).subscription(`/seq-meta-agent-${id}/**`, `seq-meta-sub-${id}`).registerType({
|
|
3170
|
+
name: `seq-meta-agent-${id}`,
|
|
3171
|
+
description: `Test sequential tag updates`,
|
|
3172
|
+
creation_schema: { type: `object` }
|
|
3173
|
+
}).spawn(`seq-meta-agent-${id}`, `entity-1`).send({ trigger: `claim` }, { from: `test` }).expectWebhook().custom(async (ctx) => {
|
|
3174
|
+
const notification = ctx.notification;
|
|
3175
|
+
const claimRes = await fetch(notification.parsed.callback, {
|
|
3176
|
+
method: `POST`,
|
|
3177
|
+
headers: {
|
|
3178
|
+
"content-type": `application/json`,
|
|
3179
|
+
authorization: `Bearer ${notification.parsed.token}`
|
|
3180
|
+
},
|
|
3181
|
+
body: JSON.stringify({
|
|
3182
|
+
epoch: notification.parsed.epoch,
|
|
3183
|
+
wakeId: notification.parsed.wake_id
|
|
3184
|
+
})
|
|
3185
|
+
});
|
|
3186
|
+
(0, vitest.expect)(claimRes.status).toBe(200);
|
|
3187
|
+
const claim = await claimRes.json();
|
|
3188
|
+
(0, vitest.expect)(claim.ok).toBe(true);
|
|
3189
|
+
(0, vitest.expect)(claim.writeToken).toBeTruthy();
|
|
3190
|
+
const tagHeaders = { authorization: `Bearer ${claim.writeToken}` };
|
|
3191
|
+
const r1 = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key1`, {
|
|
3192
|
+
method: `POST`,
|
|
3193
|
+
headers: tagHeaders,
|
|
3194
|
+
body: JSON.stringify({ value: `value1` })
|
|
3195
|
+
});
|
|
3196
|
+
(0, vitest.expect)(r1.status).toBe(200);
|
|
3197
|
+
const r2 = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key2`, {
|
|
3198
|
+
method: `POST`,
|
|
3199
|
+
headers: tagHeaders,
|
|
3200
|
+
body: JSON.stringify({ value: `value2` })
|
|
3201
|
+
});
|
|
3202
|
+
(0, vitest.expect)(r2.status).toBe(200);
|
|
3203
|
+
const getRes = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl);
|
|
3204
|
+
const entity = await getRes.json();
|
|
3205
|
+
(0, vitest.expect)(entity.tags.key1).toBe(`value1`);
|
|
3206
|
+
(0, vitest.expect)(entity.tags.key2).toBe(`value2`);
|
|
3207
|
+
}).respondDone().run();
|
|
3208
|
+
});
|
|
3209
|
+
(0, vitest.test)(`concurrent spawns under same type succeed`, async () => {
|
|
3210
|
+
const id = Date.now();
|
|
3211
|
+
await electricAgents(config.baseUrl).subscription(`/conc-spawn-agent-${id}/**`, `conc-spawn-sub-${id}`).registerType({
|
|
3212
|
+
name: `conc-spawn-agent-${id}`,
|
|
3213
|
+
description: `Test concurrent spawns`,
|
|
3214
|
+
creation_schema: { type: `object` }
|
|
3215
|
+
}).custom(async (ctx) => {
|
|
3216
|
+
const results = await Promise.all(Array.from({ length: 5 }, (_, i) => electricAgentsFetch(ctx.baseUrl, `/conc-spawn-agent-${id}/concurrent-${i}`, {
|
|
3217
|
+
method: `PUT`,
|
|
3218
|
+
body: JSON.stringify({})
|
|
3219
|
+
})));
|
|
3220
|
+
for (const res of results) (0, vitest.expect)(res.status).toBe(201);
|
|
3221
|
+
for (let i = 0; i < 5; i++) {
|
|
3222
|
+
const res = await electricAgentsFetch(ctx.baseUrl, `/conc-spawn-agent-${id}/concurrent-${i}`);
|
|
3223
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3224
|
+
}
|
|
3225
|
+
}).run();
|
|
3226
|
+
});
|
|
3227
|
+
});
|
|
3228
|
+
(0, vitest.describe)(`Electric Agents Auth Boundary`, () => {
|
|
3229
|
+
(0, vitest.test)(`write to entity stream without token is rejected`, () => {
|
|
3230
|
+
const id = Date.now();
|
|
3231
|
+
return electricAgents(config.baseUrl).subscription(`/auth-notoken-agent-${id}/**`, `auth-notoken-sub-${id}`).registerType({
|
|
3232
|
+
name: `auth-notoken-agent-${id}`,
|
|
3233
|
+
description: `Test write without token`,
|
|
3234
|
+
creation_schema: { type: `object` }
|
|
3235
|
+
}).spawn(`auth-notoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3236
|
+
const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
|
|
3237
|
+
method: `POST`,
|
|
3238
|
+
headers: { "content-type": `application/json` },
|
|
3239
|
+
body: JSON.stringify({
|
|
3240
|
+
type: `default`,
|
|
3241
|
+
key: `test-${Date.now()}`,
|
|
3242
|
+
value: { data: `should-fail` },
|
|
3243
|
+
headers: { operation: `insert` }
|
|
3244
|
+
})
|
|
3245
|
+
});
|
|
3246
|
+
(0, vitest.expect)(res.status).toBe(401);
|
|
3247
|
+
}).run();
|
|
3248
|
+
});
|
|
3249
|
+
(0, vitest.test)(`write to entity stream with wrong token is rejected`, () => {
|
|
3250
|
+
const id = Date.now();
|
|
3251
|
+
return electricAgents(config.baseUrl).subscription(`/auth-wrongtoken-agent-${id}/**`, `auth-wrongtoken-sub-${id}`).registerType({
|
|
3252
|
+
name: `auth-wrongtoken-agent-${id}`,
|
|
3253
|
+
description: `Test write with wrong token`,
|
|
3254
|
+
creation_schema: { type: `object` }
|
|
3255
|
+
}).spawn(`auth-wrongtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3256
|
+
const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
|
|
3257
|
+
method: `POST`,
|
|
3258
|
+
headers: {
|
|
3259
|
+
"content-type": `application/json`,
|
|
3260
|
+
authorization: `Bearer wrong-token`
|
|
3261
|
+
},
|
|
3262
|
+
body: JSON.stringify({
|
|
3263
|
+
type: `default`,
|
|
3264
|
+
key: `test-${Date.now()}`,
|
|
3265
|
+
value: { data: `should-fail` },
|
|
3266
|
+
headers: { operation: `insert` }
|
|
3267
|
+
})
|
|
3268
|
+
});
|
|
3269
|
+
(0, vitest.expect)(res.status).toBe(401);
|
|
3270
|
+
}).run();
|
|
3271
|
+
});
|
|
3272
|
+
(0, vitest.test)(`spawn does not expose a public entity write token`, () => {
|
|
3273
|
+
const id = Date.now();
|
|
3274
|
+
return electricAgents(config.baseUrl).subscription(`/auth-goodtoken-agent-${id}/**`, `auth-goodtoken-sub-${id}`).registerType({
|
|
3275
|
+
name: `auth-goodtoken-agent-${id}`,
|
|
3276
|
+
description: `Test write with correct token`,
|
|
3277
|
+
creation_schema: { type: `object` }
|
|
3278
|
+
}).spawn(`auth-goodtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3279
|
+
(0, vitest.expect)(ctx.currentWriteToken).toBeNull();
|
|
3280
|
+
}).run();
|
|
3281
|
+
});
|
|
3282
|
+
(0, vitest.test)(`tag update without token is rejected`, () => {
|
|
3283
|
+
const id = Date.now();
|
|
3284
|
+
return electricAgents(config.baseUrl).subscription(`/auth-meta-notoken-agent-${id}/**`, `auth-meta-notoken-sub-${id}`).registerType({
|
|
3285
|
+
name: `auth-meta-notoken-agent-${id}`,
|
|
3286
|
+
description: `Test tag update without token`,
|
|
3287
|
+
creation_schema: { type: `object` }
|
|
3288
|
+
}).spawn(`auth-meta-notoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3289
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/key`)}`, {
|
|
3290
|
+
method: `POST`,
|
|
3291
|
+
headers: { "content-type": `application/json` },
|
|
3292
|
+
body: JSON.stringify({ value: `value` })
|
|
3293
|
+
});
|
|
3294
|
+
(0, vitest.expect)(res.status).toBe(401);
|
|
3295
|
+
}).run();
|
|
3296
|
+
});
|
|
3297
|
+
(0, vitest.test)(`spawn does not expose a public tag write token`, () => {
|
|
3298
|
+
const id = Date.now();
|
|
3299
|
+
return electricAgents(config.baseUrl).subscription(`/auth-meta-goodtoken-agent-${id}/**`, `auth-meta-goodtoken-sub-${id}`).registerType({
|
|
3300
|
+
name: `auth-meta-goodtoken-agent-${id}`,
|
|
3301
|
+
description: `Test tag update with correct token`,
|
|
3302
|
+
creation_schema: { type: `object` }
|
|
3303
|
+
}).spawn(`auth-meta-goodtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3304
|
+
(0, vitest.expect)(ctx.currentWriteToken).toBeNull();
|
|
3305
|
+
}).run();
|
|
3306
|
+
});
|
|
3307
|
+
(0, vitest.test)(`send remains unauthenticated`, () => {
|
|
3308
|
+
const id = Date.now();
|
|
3309
|
+
return electricAgents(config.baseUrl).subscription(`/auth-send-noauth-agent-${id}/**`, `auth-send-noauth-sub-${id}`).registerType({
|
|
3310
|
+
name: `auth-send-noauth-agent-${id}`,
|
|
3311
|
+
description: `Test send without auth`,
|
|
3312
|
+
creation_schema: { type: `object` }
|
|
3313
|
+
}).spawn(`auth-send-noauth-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3314
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/send`)}`, {
|
|
3315
|
+
method: `POST`,
|
|
3316
|
+
headers: { "content-type": `application/json` },
|
|
3317
|
+
body: JSON.stringify({
|
|
3318
|
+
from: `tester`,
|
|
3319
|
+
payload: `hi`
|
|
3320
|
+
})
|
|
3321
|
+
});
|
|
3322
|
+
(0, vitest.expect)(res.status).toBe(204);
|
|
3323
|
+
}).expectWebhook().respondDone().run();
|
|
3324
|
+
});
|
|
3325
|
+
(0, vitest.test)(`GET entity does not leak write_token`, () => {
|
|
3326
|
+
const id = Date.now();
|
|
3327
|
+
return electricAgents(config.baseUrl).subscription(`/auth-noleak-agent-${id}/**`, `auth-noleak-sub-${id}`).registerType({
|
|
3328
|
+
name: `auth-noleak-agent-${id}`,
|
|
3329
|
+
description: `Test GET does not leak write_token`,
|
|
3330
|
+
creation_schema: { type: `object` }
|
|
3331
|
+
}).spawn(`auth-noleak-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3332
|
+
const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
|
|
3333
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3334
|
+
const entity = await res.json();
|
|
3335
|
+
(0, vitest.expect)(entity.write_token).toBeUndefined();
|
|
3336
|
+
(0, vitest.expect)(entity.subscription_id).toBeUndefined();
|
|
3337
|
+
}).run();
|
|
3338
|
+
});
|
|
3339
|
+
vitest.test.skip(`list entities does not leak write_token`, () => {
|
|
3340
|
+
const id = Date.now();
|
|
3341
|
+
return electricAgents(config.baseUrl).subscription(`/auth-listnoleak-agent-${id}/**`, `auth-listnoleak-sub-${id}`).registerType({
|
|
3342
|
+
name: `auth-listnoleak-agent-${id}`,
|
|
3343
|
+
description: `Test list does not leak write_token`,
|
|
3344
|
+
creation_schema: { type: `object` }
|
|
3345
|
+
}).spawn(`auth-listnoleak-agent-${id}`, `entity-1`).custom(async (ctx) => {
|
|
3346
|
+
const entities = await fetchShapeRows(ctx.baseUrl, `entities`);
|
|
3347
|
+
for (const entity of entities) {
|
|
3348
|
+
(0, vitest.expect)(entity).not.toHaveProperty(`write_token`);
|
|
3349
|
+
(0, vitest.expect)(entity).not.toHaveProperty(`subscription_id`);
|
|
3350
|
+
}
|
|
3351
|
+
}).run();
|
|
3352
|
+
});
|
|
3353
|
+
(0, vitest.test)(`write to non-entity stream requires no auth`, async () => {
|
|
3354
|
+
const id = Date.now();
|
|
3355
|
+
const streamPath = `/v1/stream/plain-stream-auth-test-${id}`;
|
|
3356
|
+
const createRes = await fetch(`${config.baseUrl}${streamPath}`, {
|
|
3357
|
+
method: `PUT`,
|
|
3358
|
+
headers: { "Content-Type": `text/plain` },
|
|
3359
|
+
body: `initial data`
|
|
3360
|
+
});
|
|
3361
|
+
(0, vitest.expect)(createRes.status).toBe(201);
|
|
3362
|
+
const writeRes = await fetch(`${config.baseUrl}${streamPath}`, {
|
|
3363
|
+
method: `POST`,
|
|
3364
|
+
headers: { "Content-Type": `text/plain` },
|
|
3365
|
+
body: ` more data`
|
|
3366
|
+
});
|
|
3367
|
+
(0, vitest.expect)([200, 204].includes(writeRes.status)).toBe(true);
|
|
3368
|
+
});
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
function runCliConformanceTests(config) {
|
|
3372
|
+
(0, vitest.describe)(`CLI — Entity Types`, () => {
|
|
3373
|
+
(0, vitest.test)(`types lists registered types`, async () => {
|
|
3374
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3375
|
+
name: `cli-types-test`,
|
|
3376
|
+
description: `Type for CLI types test`
|
|
3377
|
+
}).exec(`types`).expectExitCode(0).expectStdout(/cli-types-test/).expectStdout(/Type for CLI types test/).run();
|
|
3378
|
+
});
|
|
3379
|
+
(0, vitest.test)(`types inspect shows type details`, async () => {
|
|
3380
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3381
|
+
name: `cli-inspect-type`,
|
|
3382
|
+
description: `Type for CLI inspect test`
|
|
3383
|
+
}).exec(`types`, `inspect`, `cli-inspect-type`).expectExitCode(0).expectStdout(/cli-inspect-type/).run();
|
|
3384
|
+
});
|
|
3385
|
+
(0, vitest.test)(`types inspect nonexistent type fails`, async () => {
|
|
3386
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`types`, `inspect`, `nonexistent-type-xyz`).expectExitCode(1).expectStderr(/Error/).run();
|
|
3387
|
+
});
|
|
3388
|
+
(0, vitest.test)(`types delete removes type`, async () => {
|
|
3389
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3390
|
+
name: `cli-delete-type`,
|
|
3391
|
+
description: `Type to delete`
|
|
3392
|
+
}).exec(`types`, `delete`, `cli-delete-type`).expectExitCode(0).expectStdout(/Deleted/).exec(`types`).expectStdoutNot(/cli-delete-type/).run();
|
|
3393
|
+
});
|
|
3394
|
+
});
|
|
3395
|
+
(0, vitest.describe)(`CLI — Spawn`, () => {
|
|
3396
|
+
(0, vitest.test)(`spawn creates entity and reports URL`, async () => {
|
|
3397
|
+
const id = `cli-spawn-${Date.now()}`;
|
|
3398
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3399
|
+
name: `cli-spawn-type`,
|
|
3400
|
+
description: `Type for CLI spawn test`
|
|
3401
|
+
}).setupSubscription(`/cli-spawn-type/**`, `cli-spawn-sub`).exec(`spawn`, `/cli-spawn-type/${id}`).expectExitCode(0).expectStdout(/Spawned/).verifyApi(async (baseUrl) => {
|
|
3402
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-spawn-type/${id}`)}`);
|
|
3403
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3404
|
+
const entity = await res.json();
|
|
3405
|
+
(0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
|
|
3406
|
+
}).run();
|
|
3407
|
+
});
|
|
3408
|
+
(0, vitest.test)(`spawn with --args passes arguments`, async () => {
|
|
3409
|
+
const id = `cli-args-${Date.now()}`;
|
|
3410
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3411
|
+
name: `cli-args-type`,
|
|
3412
|
+
description: `Type for CLI args test`,
|
|
3413
|
+
creation_schema: { type: `object` }
|
|
3414
|
+
}).setupSubscription(`/cli-args-type/**`, `cli-args-sub`).exec(`spawn`, `/cli-args-type/${id}`, `--args`, `{"key":"value"}`).expectExitCode(0).expectStdout(/Spawned/).run();
|
|
3415
|
+
});
|
|
3416
|
+
(0, vitest.test)(`spawn unregistered type exits non-zero`, async () => {
|
|
3417
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/nonexistent-type-xyz/some-id`).expectExitCode(1).custom((last) => {
|
|
3418
|
+
(0, vitest.expect)(`${last.stdout}\n${last.stderr}`).toMatch(/Entity type "nonexistent-type-xyz" not found/);
|
|
3419
|
+
}).run();
|
|
3420
|
+
});
|
|
3421
|
+
});
|
|
3422
|
+
(0, vitest.describe)(`CLI — Process Listing (ps)`, () => {
|
|
3423
|
+
(0, vitest.test)(`ps lists spawned entities`, async () => {
|
|
3424
|
+
const id = `cli-ps-${Date.now()}`;
|
|
3425
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3426
|
+
name: `cli-ps-type`,
|
|
3427
|
+
description: `Type for CLI ps test`
|
|
3428
|
+
}).setupSubscription(`/cli-ps-type/**`, `cli-ps-sub`).exec(`spawn`, `/cli-ps-type/${id}`).expectExitCode(0).exec(`ps`).expectExitCode(0).expectStdout(/cli-ps-type/).run();
|
|
3429
|
+
});
|
|
3430
|
+
(0, vitest.test)(`ps --type filters by entity type`, async () => {
|
|
3431
|
+
const id = `cli-filter-${Date.now()}`;
|
|
3432
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3433
|
+
name: `cli-filter-type`,
|
|
3434
|
+
description: `Type for CLI filter test`
|
|
3435
|
+
}).setupSubscription(`/cli-filter-type/**`, `cli-filter-sub`).exec(`spawn`, `/cli-filter-type/${id}`).expectExitCode(0).exec(`ps`, `--type`, `cli-filter-type`).expectExitCode(0).expectStdout(/cli-filter-type/).run();
|
|
3436
|
+
});
|
|
3437
|
+
});
|
|
3438
|
+
(0, vitest.describe)(`CLI — Send`, () => {
|
|
3439
|
+
(0, vitest.test)(`send delivers message and verifies stream`, async () => {
|
|
3440
|
+
const id = `cli-send-${Date.now()}`;
|
|
3441
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3442
|
+
name: `cli-send-type`,
|
|
3443
|
+
description: `Type for CLI send test`
|
|
3444
|
+
}).setupSubscription(`/cli-send-type/**`, `cli-send-sub`).exec(`spawn`, `/cli-send-type/${id}`).expectExitCode(0).exec(`send`, `/cli-send-type/${id}`, `hello world`).expectExitCode(0).expectStdout(/Message sent/).verifyApi(async (baseUrl) => {
|
|
3445
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-send-type/${id}`)}`);
|
|
3446
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3447
|
+
const entity = await res.json();
|
|
3448
|
+
const streams = entity.streams;
|
|
3449
|
+
const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
|
|
3450
|
+
const events = await streamRes.json();
|
|
3451
|
+
(0, vitest.expect)(events.length).toBeGreaterThanOrEqual(1);
|
|
3452
|
+
const msgEvent = events.find((e) => e.type === `message_received`);
|
|
3453
|
+
(0, vitest.expect)(msgEvent).toBeDefined();
|
|
3454
|
+
const payload = msgEvent.value?.payload;
|
|
3455
|
+
(0, vitest.expect)(payload.text).toBe(`hello world`);
|
|
3456
|
+
}).run();
|
|
3457
|
+
});
|
|
3458
|
+
(0, vitest.test)(`send to nonexistent entity fails`, async () => {
|
|
3459
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`send`, `/nonexistent/entity`, `hello`).expectExitCode(1).expectStderr(/Error/).run();
|
|
3460
|
+
});
|
|
3461
|
+
});
|
|
3462
|
+
(0, vitest.describe)(`CLI — Inspect`, () => {
|
|
3463
|
+
(0, vitest.test)(`inspect shows entity details`, async () => {
|
|
3464
|
+
const id = `cli-inspect-${Date.now()}`;
|
|
3465
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3466
|
+
name: `cli-inspect-etype`,
|
|
3467
|
+
description: `Type for CLI inspect test`
|
|
3468
|
+
}).setupSubscription(`/cli-inspect-etype/**`, `cli-inspect-sub`).exec(`spawn`, `/cli-inspect-etype/${id}`).expectExitCode(0).exec(`inspect`, `/cli-inspect-etype/${id}`).expectExitCode(0).expectStdout(/running|idle/).verifyApi(async (baseUrl) => {
|
|
3469
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-inspect-etype/${id}`)}`);
|
|
3470
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3471
|
+
const entity = await res.json();
|
|
3472
|
+
(0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
|
|
3473
|
+
}).run();
|
|
3474
|
+
});
|
|
3475
|
+
(0, vitest.test)(`inspect nonexistent entity fails`, async () => {
|
|
3476
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`inspect`, `/nonexistent/entity`).expectExitCode(1).expectStderr(/Error/).run();
|
|
3477
|
+
});
|
|
3478
|
+
});
|
|
3479
|
+
(0, vitest.describe)(`CLI — Kill`, () => {
|
|
3480
|
+
(0, vitest.test)(`kill stops a running entity`, async () => {
|
|
3481
|
+
const id = `cli-kill-${Date.now()}`;
|
|
3482
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3483
|
+
name: `cli-kill-type`,
|
|
3484
|
+
description: `Type for CLI kill test`
|
|
3485
|
+
}).setupSubscription(`/cli-kill-type/**`, `cli-kill-sub`).exec(`spawn`, `/cli-kill-type/${id}`).expectExitCode(0).exec(`kill`, `/cli-kill-type/${id}`).expectExitCode(0).expectStdout(/Killed/).verifyApi(async (baseUrl) => {
|
|
3486
|
+
const entity = await pollEntityStatus(baseUrl, `/cli-kill-type/${id}`, [`stopped`]);
|
|
3487
|
+
(0, vitest.expect)(entity.status).toBe(`stopped`);
|
|
3488
|
+
}).run();
|
|
3489
|
+
}, 15e3);
|
|
3490
|
+
(0, vitest.test)(`kill nonexistent entity fails`, async () => {
|
|
3491
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`kill`, `/nonexistent/entity`).expectExitCode(1).expectStderr(/Error/).run();
|
|
3492
|
+
});
|
|
3493
|
+
});
|
|
3494
|
+
(0, vitest.describe)(`CLI — Full Lifecycle`, () => {
|
|
3495
|
+
(0, vitest.test)(`spawn → send → inspect → kill with API verification`, async () => {
|
|
3496
|
+
const id = `cli-lifecycle-${Date.now()}`;
|
|
3497
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3498
|
+
name: `cli-lifecycle-type`,
|
|
3499
|
+
description: `Type for full lifecycle test`
|
|
3500
|
+
}).setupSubscription(`/cli-lifecycle-type/**`, `cli-lifecycle-sub`).exec(`spawn`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/Spawned/).verifyApi(async (baseUrl) => {
|
|
3501
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-lifecycle-type/${id}`)}`);
|
|
3502
|
+
(0, vitest.expect)(res.status, `entity should exist after spawn`).toBe(200);
|
|
3503
|
+
const entity = await res.json();
|
|
3504
|
+
(0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
|
|
3505
|
+
}).exec(`send`, `/cli-lifecycle-type/${id}`, `test message`).expectExitCode(0).expectStdout(/Message sent/).exec(`inspect`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/running|idle/).exec(`kill`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/Killed/).verifyApi(async (baseUrl) => {
|
|
3506
|
+
const entity = await pollEntityStatus(baseUrl, `/cli-lifecycle-type/${id}`, [`stopped`]);
|
|
3507
|
+
(0, vitest.expect)(entity.status).toBe(`stopped`);
|
|
3508
|
+
}).exec(`ps`, `--status`, `running`).expectExitCode(0).expectStdoutNot(new RegExp(id)).run();
|
|
3509
|
+
}, 15e3);
|
|
3510
|
+
(0, vitest.test)(`send to stopped entity fails`, async () => {
|
|
3511
|
+
const id = `cli-stopped-${Date.now()}`;
|
|
3512
|
+
await cliTest(config.baseUrl, config.cliBin).setupType({
|
|
3513
|
+
name: `cli-stopped-type`,
|
|
3514
|
+
description: `Type for stopped entity test`
|
|
3515
|
+
}).setupSubscription(`/cli-stopped-type/**`, `cli-stopped-sub`).exec(`spawn`, `/cli-stopped-type/${id}`).expectExitCode(0).exec(`kill`, `/cli-stopped-type/${id}`).expectExitCode(0).exec(`send`, `/cli-stopped-type/${id}`, `should fail`).expectExitCode(1).expectStderr(/Error/).run();
|
|
3516
|
+
});
|
|
3517
|
+
});
|
|
3518
|
+
(0, vitest.describe)(`CLI — Usage Errors`, () => {
|
|
3519
|
+
(0, vitest.test)(`no arguments shows usage`, async () => {
|
|
3520
|
+
await cliTest(config.baseUrl, config.cliBin).exec().expectExitCode(1).run();
|
|
3521
|
+
});
|
|
3522
|
+
(0, vitest.test)(`spawn without arguments shows usage`, async () => {
|
|
3523
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`spawn`).expectExitCode(1).run();
|
|
3524
|
+
});
|
|
3525
|
+
(0, vitest.test)(`send without URL shows usage`, async () => {
|
|
3526
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`send`).expectExitCode(1).run();
|
|
3527
|
+
});
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3530
|
+
function runMockAgentTests(config) {
|
|
3531
|
+
async function spawnEntity(baseUrl, typeName, instanceId) {
|
|
3532
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/${encodeURIComponent(typeName)}/${encodeURIComponent(instanceId)}`)}`, {
|
|
3533
|
+
method: `PUT`,
|
|
3534
|
+
headers: { "content-type": `application/json` },
|
|
3535
|
+
body: JSON.stringify({})
|
|
3536
|
+
});
|
|
3537
|
+
(0, vitest.expect)(res.ok, `spawn should succeed: ${res.status}`).toBe(true);
|
|
3538
|
+
return await res.json();
|
|
3539
|
+
}
|
|
3540
|
+
async function sendMessage(baseUrl, entityUrl, text) {
|
|
3541
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`${entityUrl}/send`)}`, {
|
|
3542
|
+
method: `POST`,
|
|
3543
|
+
headers: { "content-type": `application/json` },
|
|
3544
|
+
body: JSON.stringify({
|
|
3545
|
+
payload: { text },
|
|
3546
|
+
from: `tester`
|
|
3547
|
+
})
|
|
3548
|
+
});
|
|
3549
|
+
(0, vitest.expect)(res.ok || res.status === 204, `send should succeed: ${res.status}`).toBe(true);
|
|
3550
|
+
}
|
|
3551
|
+
async function pollForAgentResponse(baseUrl, entityUrl, timeoutMs = 1e4) {
|
|
3552
|
+
const start = Date.now();
|
|
3553
|
+
while (Date.now() - start < timeoutMs) {
|
|
3554
|
+
const entityRes = await fetch(`${baseUrl}${routeControlPlanePath(entityUrl)}`);
|
|
3555
|
+
if (!entityRes.ok) throw new Error(`Entity ${entityUrl} not found`);
|
|
3556
|
+
const entity = await entityRes.json();
|
|
3557
|
+
const streams = entity.streams;
|
|
3558
|
+
const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
|
|
3559
|
+
const events = await streamRes.json();
|
|
3560
|
+
const hasRunComplete = events.some((e) => e.type === `run` && e.headers?.operation === `update`);
|
|
3561
|
+
if (hasRunComplete) return events;
|
|
3562
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
3563
|
+
}
|
|
3564
|
+
throw new Error(`Agent did not respond within ${timeoutMs}ms`);
|
|
3565
|
+
}
|
|
3566
|
+
(0, vitest.describe)(`Mock Agent — End-to-End Pipeline`, () => {
|
|
3567
|
+
(0, vitest.test)(`send message → mock agent writes State Protocol response`, async () => {
|
|
3568
|
+
const id = `mock-e2e-${Date.now()}`;
|
|
3569
|
+
const entityUrl = `/chat/${id}`;
|
|
3570
|
+
await spawnEntity(config.baseUrl, `chat`, id);
|
|
3571
|
+
await sendMessage(config.baseUrl, entityUrl, `Say hello`);
|
|
3572
|
+
const events = await pollForAgentResponse(config.baseUrl, entityUrl);
|
|
3573
|
+
const spEvents = events.filter((e) => e.type && e.key && e.headers);
|
|
3574
|
+
(0, vitest.expect)(spEvents.some((e) => e.type === `run`)).toBe(true);
|
|
3575
|
+
(0, vitest.expect)(spEvents.some((e) => e.type === `step`)).toBe(true);
|
|
3576
|
+
(0, vitest.expect)(spEvents.some((e) => e.type === `text`)).toBe(true);
|
|
3577
|
+
checkStateProtocolInvariants(spEvents);
|
|
3578
|
+
}, 15e3);
|
|
3579
|
+
(0, vitest.test)(`mock agent response contains expected text content`, async () => {
|
|
3580
|
+
const id = `mock-text-${Date.now()}`;
|
|
3581
|
+
const entityUrl = `/chat/${id}`;
|
|
3582
|
+
await spawnEntity(config.baseUrl, `chat`, id);
|
|
3583
|
+
await sendMessage(config.baseUrl, entityUrl, `What is 2+2?`);
|
|
3584
|
+
const events = await pollForAgentResponse(config.baseUrl, entityUrl);
|
|
3585
|
+
const textComplete = events.find((e) => e.type === `text` && e.headers?.operation === `update`);
|
|
3586
|
+
(0, vitest.expect)(textComplete).toBeDefined();
|
|
3587
|
+
const value = textComplete.value;
|
|
3588
|
+
(0, vitest.expect)(value.status).toBe(`completed`);
|
|
3589
|
+
}, 15e3);
|
|
3590
|
+
(0, vitest.test)(`mock agent writes text deltas for streaming`, async () => {
|
|
3591
|
+
const id = `mock-deltas-${Date.now()}`;
|
|
3592
|
+
const entityUrl = `/chat/${id}`;
|
|
3593
|
+
await spawnEntity(config.baseUrl, `chat`, id);
|
|
3594
|
+
await sendMessage(config.baseUrl, entityUrl, `hello`);
|
|
3595
|
+
const events = await pollForAgentResponse(config.baseUrl, entityUrl);
|
|
3596
|
+
const deltas = events.filter((e) => e.type === `text_delta`);
|
|
3597
|
+
(0, vitest.expect)(deltas.length).toBeGreaterThan(0);
|
|
3598
|
+
for (const delta of deltas) {
|
|
3599
|
+
const val = delta.value;
|
|
3600
|
+
(0, vitest.expect)(val.delta).toBeDefined();
|
|
3601
|
+
(0, vitest.expect)(typeof val.delta).toBe(`string`);
|
|
3602
|
+
}
|
|
3603
|
+
}, 15e3);
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
function runMockAgentCliTests(config) {
|
|
3607
|
+
(0, vitest.describe)(`CLI — Mock Agent End-to-End`, () => {
|
|
3608
|
+
(0, vitest.test)(`spawn → send → agent responds with State Protocol events`, async () => {
|
|
3609
|
+
const id = `cli-mock-${Date.now()}`;
|
|
3610
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/chat/${id}`).expectExitCode(0).expectStdout(/Spawned/).exec(`send`, `/chat/${id}`, `hello from CLI`).expectExitCode(0).expectStdout(/Message sent/).wait(3e3).verifyApi(async (baseUrl) => {
|
|
3611
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/chat/${id}`)}`);
|
|
3612
|
+
(0, vitest.expect)(res.status, `entity should exist`).toBe(200);
|
|
3613
|
+
const entity = await res.json();
|
|
3614
|
+
const streams = entity.streams;
|
|
3615
|
+
const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
|
|
3616
|
+
const events = await streamRes.json();
|
|
3617
|
+
(0, vitest.expect)(events.some((e) => e.type === `run`), `stream should contain run events from agent`).toBe(true);
|
|
3618
|
+
(0, vitest.expect)(events.some((e) => e.type === `text`), `stream should contain text events from agent`).toBe(true);
|
|
3619
|
+
checkStateProtocolInvariants(events.filter((e) => e.type && e.key && e.headers));
|
|
3620
|
+
}).run();
|
|
3621
|
+
}, 2e4);
|
|
3622
|
+
(0, vitest.test)(`inspect shows entity after mock agent processes message`, async () => {
|
|
3623
|
+
const id = `cli-inspect-mock-${Date.now()}`;
|
|
3624
|
+
await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/chat/${id}`).expectExitCode(0).exec(`send`, `/chat/${id}`, `test message`).expectExitCode(0).wait(3e3).exec(`inspect`, `/chat/${id}`).expectExitCode(0).expectStdout(/running|idle/).verifyApi(async (baseUrl) => {
|
|
3625
|
+
const res = await fetch(`${baseUrl}${routeControlPlanePath(`/chat/${id}`)}`);
|
|
3626
|
+
(0, vitest.expect)(res.status).toBe(200);
|
|
3627
|
+
const entity = await res.json();
|
|
3628
|
+
const streams = entity.streams;
|
|
3629
|
+
const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
|
|
3630
|
+
const events = await streamRes.json();
|
|
3631
|
+
const hasRunComplete = events.some((e) => e.type === `run` && e.headers?.operation === `update`);
|
|
3632
|
+
(0, vitest.expect)(hasRunComplete, `mock agent should have written run completion event`).toBe(true);
|
|
3633
|
+
}).run();
|
|
3634
|
+
}, 2e4);
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
//#endregion
|
|
3639
|
+
//#region src/mock-stream.ts
|
|
3640
|
+
function createMockStreamFn(text) {
|
|
3641
|
+
return () => {
|
|
3642
|
+
const partial = {
|
|
3643
|
+
role: `assistant`,
|
|
3644
|
+
stopReason: `stop`,
|
|
3645
|
+
content: [],
|
|
3646
|
+
api: `anthropic-messages`,
|
|
3647
|
+
provider: `anthropic`,
|
|
3648
|
+
model: `mock`,
|
|
3649
|
+
usage: {
|
|
3650
|
+
input: 10,
|
|
3651
|
+
output: 5,
|
|
3652
|
+
cacheRead: 0,
|
|
3653
|
+
cacheWrite: 0,
|
|
3654
|
+
totalTokens: 15,
|
|
3655
|
+
cost: {
|
|
3656
|
+
input: 0,
|
|
3657
|
+
output: 0,
|
|
3658
|
+
cacheRead: 0,
|
|
3659
|
+
cacheWrite: 0,
|
|
3660
|
+
total: 0
|
|
3661
|
+
}
|
|
3662
|
+
},
|
|
3663
|
+
timestamp: Date.now()
|
|
3664
|
+
};
|
|
3665
|
+
partial.content.push({
|
|
3666
|
+
type: `text`,
|
|
3667
|
+
text: ``
|
|
3668
|
+
});
|
|
3669
|
+
const events = [];
|
|
3670
|
+
events.push({
|
|
3671
|
+
type: `start`,
|
|
3672
|
+
partial
|
|
3673
|
+
});
|
|
3674
|
+
events.push({
|
|
3675
|
+
type: `text_start`,
|
|
3676
|
+
contentIndex: 0,
|
|
3677
|
+
partial
|
|
3678
|
+
});
|
|
3679
|
+
const chunks = text.match(/.{1,10}/g) ?? [text];
|
|
3680
|
+
for (const chunk of chunks) {
|
|
3681
|
+
const textContent$1 = partial.content[0];
|
|
3682
|
+
if (textContent$1) textContent$1.text += chunk;
|
|
3683
|
+
events.push({
|
|
3684
|
+
type: `text_delta`,
|
|
3685
|
+
contentIndex: 0,
|
|
3686
|
+
delta: chunk,
|
|
3687
|
+
partial
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
const textContent = partial.content[0];
|
|
3691
|
+
events.push({
|
|
3692
|
+
type: `text_end`,
|
|
3693
|
+
contentIndex: 0,
|
|
3694
|
+
content: textContent?.text ?? ``,
|
|
3695
|
+
partial
|
|
3696
|
+
});
|
|
3697
|
+
events.push({
|
|
3698
|
+
type: `done`,
|
|
3699
|
+
reason: `stop`,
|
|
3700
|
+
message: partial
|
|
3701
|
+
});
|
|
3702
|
+
let resolved = false;
|
|
3703
|
+
let resolveResult;
|
|
3704
|
+
const resultPromise = new Promise((res) => {
|
|
3705
|
+
resolveResult = res;
|
|
3706
|
+
});
|
|
3707
|
+
const asyncIterable = {
|
|
3708
|
+
[Symbol.asyncIterator]() {
|
|
3709
|
+
let idx = 0;
|
|
3710
|
+
return { async next() {
|
|
3711
|
+
if (idx < events.length) {
|
|
3712
|
+
const value = events[idx++];
|
|
3713
|
+
if (!resolved && value.type === `done`) {
|
|
3714
|
+
resolved = true;
|
|
3715
|
+
resolveResult(value.message);
|
|
3716
|
+
}
|
|
3717
|
+
return {
|
|
3718
|
+
value,
|
|
3719
|
+
done: false
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
return {
|
|
3723
|
+
value: void 0,
|
|
3724
|
+
done: true
|
|
3725
|
+
};
|
|
3726
|
+
} };
|
|
3727
|
+
},
|
|
3728
|
+
result() {
|
|
3729
|
+
return resultPromise;
|
|
3730
|
+
}
|
|
3731
|
+
};
|
|
3732
|
+
return asyncIterable;
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
//#endregion
|
|
3737
|
+
exports.CliScenario = CliScenario
|
|
3738
|
+
exports.ElectricAgentsScenario = ElectricAgentsScenario
|
|
3739
|
+
exports.ServeEndpointReceiver = ServeEndpointReceiver
|
|
3740
|
+
exports.applyElectricAgentsAction = applyElectricAgentsAction
|
|
3741
|
+
exports.checkInvariants = checkInvariants
|
|
3742
|
+
exports.checkStateProtocolInvariants = checkStateProtocolInvariants
|
|
3743
|
+
exports.cliTest = cliTest
|
|
3744
|
+
exports.createMockStreamFn = createMockStreamFn
|
|
3745
|
+
exports.electricAgents = electricAgents
|
|
3746
|
+
exports.enabledElectricAgentsActions = enabledElectricAgentsActions
|
|
3747
|
+
exports.runCliConformanceTests = runCliConformanceTests
|
|
3748
|
+
exports.runElectricAgentsConformanceTests = runElectricAgentsConformanceTests
|
|
3749
|
+
exports.runMockAgentCliTests = runMockAgentCliTests
|
|
3750
|
+
exports.runMockAgentTests = runMockAgentTests
|