@bespokeagentics/microdots-host 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/package.json +25 -0
- package/src/index.ts +144 -0
- package/src/loader.ts +119 -0
- package/src/mounting.test.ts +214 -0
- package/src/mounting.ts +90 -0
- package/src/placementChecks.test.ts +593 -0
- package/src/placementChecks.ts +428 -0
- package/src/registry.ts +63 -0
- package/src/routes.ts +156 -0
- package/src/rules.test.ts +598 -0
- package/src/rules.ts +448 -0
- package/src/slots.test.ts +46 -0
- package/src/slots.ts +58 -0
- package/src/wire.test.ts +631 -0
- package/src/wire.ts +560 -0
- package/src/wireEngine.test.ts +810 -0
- package/src/wireEngine.ts +468 -0
package/src/wire.test.ts
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { Option, Schema as S } from 'effect'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { describe, expect, test } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ManifestAttribute,
|
|
7
|
+
ManifestEvent,
|
|
8
|
+
ManifestTag,
|
|
9
|
+
} from '@bespokeagentics/microdots-element'
|
|
10
|
+
|
|
11
|
+
import type { RouteDefinition, RouteTable } from './routes.ts'
|
|
12
|
+
import {
|
|
13
|
+
HostTopology,
|
|
14
|
+
TopologyPlacement,
|
|
15
|
+
type Wire,
|
|
16
|
+
deriveWireState,
|
|
17
|
+
topologyRouteTable,
|
|
18
|
+
} from './wire.ts'
|
|
19
|
+
|
|
20
|
+
/* ============================================================
|
|
21
|
+
Synthetic manifest + table builders. Everything below is deliberately
|
|
22
|
+
minimal: one source tag with one event, one target tag with one
|
|
23
|
+
attribute, and just enough route table to place them.
|
|
24
|
+
============================================================ */
|
|
25
|
+
|
|
26
|
+
const attribute = (
|
|
27
|
+
name: string,
|
|
28
|
+
type: ManifestAttribute['type'],
|
|
29
|
+
values?: ReadonlyArray<string>,
|
|
30
|
+
): ManifestAttribute => ({
|
|
31
|
+
name,
|
|
32
|
+
type,
|
|
33
|
+
required: false,
|
|
34
|
+
live: true,
|
|
35
|
+
ownership: 'dot',
|
|
36
|
+
...(values === undefined ? {} : { values }),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `properties` mirrors the emitter's draft-2020-12 document shape —
|
|
41
|
+
* `{schema: {properties}}` — because that is what `deriveWireState` reads.
|
|
42
|
+
* Omitting it produces an event with no `jsonSchema`, the emitter's
|
|
43
|
+
* "could not render" case.
|
|
44
|
+
*/
|
|
45
|
+
const manifestEvent = (
|
|
46
|
+
name: string,
|
|
47
|
+
properties?: Readonly<Record<string, unknown>>,
|
|
48
|
+
): ManifestEvent => ({
|
|
49
|
+
name,
|
|
50
|
+
payload: {
|
|
51
|
+
ref: `@microdots/test/contract#${name}`,
|
|
52
|
+
...(properties === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: {
|
|
55
|
+
jsonSchema: {
|
|
56
|
+
dialect: 'draft-2020-12',
|
|
57
|
+
schema: { type: 'object', properties },
|
|
58
|
+
definitions: {},
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const sourceTag = (event: ManifestEvent): ManifestTag => ({
|
|
65
|
+
tag: 'price-ticker',
|
|
66
|
+
attributes: [],
|
|
67
|
+
events: [event],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const targetTag = (spec: ManifestAttribute): ManifestTag => ({
|
|
71
|
+
tag: 'fleet-health',
|
|
72
|
+
attributes: [spec],
|
|
73
|
+
events: [],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const route = (path: string, tags: ReadonlyArray<string>): RouteDefinition => ({
|
|
77
|
+
path,
|
|
78
|
+
label: path,
|
|
79
|
+
title: path,
|
|
80
|
+
sectionIds: tags.map(tag => `${tag}-section`),
|
|
81
|
+
mounts: tags.map(tag => ({ tag, slotId: `${tag}-slot` })),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const tableOf = (
|
|
85
|
+
first: RouteDefinition,
|
|
86
|
+
...rest: ReadonlyArray<RouteDefinition>
|
|
87
|
+
): RouteTable => ({ routes: [first, ...rest], fallback: first })
|
|
88
|
+
|
|
89
|
+
/** Both ends on one route: the placement half of `live`. */
|
|
90
|
+
const SHARED_TABLE = tableOf(route('/all', ['price-ticker', 'fleet-health']))
|
|
91
|
+
|
|
92
|
+
/** Both ends placed, but never together. */
|
|
93
|
+
const SPLIT_TABLE = tableOf(
|
|
94
|
+
route('/price', ['price-ticker']),
|
|
95
|
+
route('/fleet', ['fleet-health']),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const STRING_MANIFESTS: ReadonlyArray<ManifestTag> = [
|
|
99
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'string' } })),
|
|
100
|
+
targetTag(attribute('region', 'string')),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const directWire: Wire = {
|
|
104
|
+
id: 'w1',
|
|
105
|
+
from: 'price-ticker',
|
|
106
|
+
event: 'quote-changed',
|
|
107
|
+
field: 'symbol',
|
|
108
|
+
fieldType: 'string',
|
|
109
|
+
to: 'fleet-health',
|
|
110
|
+
input: 'region',
|
|
111
|
+
inputType: 'string',
|
|
112
|
+
transform: { _tag: 'direct' },
|
|
113
|
+
envs: ['dev'],
|
|
114
|
+
plain: 'When the quote changes, set the region to its symbol.',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The failure mode: a wire whose state is wrong is either a red herring on
|
|
118
|
+
// the Wiring screen (live shown as draft) or a silently dead broker (draft
|
|
119
|
+
// shown as live) — and the whole point of DERIVING state is that these tests
|
|
120
|
+
// pin the derivation, not a stored copy that rots.
|
|
121
|
+
describe('deriveWireState', () => {
|
|
122
|
+
test('derives live when types match and a route mounts both ends', () => {
|
|
123
|
+
expect(
|
|
124
|
+
deriveWireState(directWire, {
|
|
125
|
+
manifests: STRING_MANIFESTS,
|
|
126
|
+
table: SHARED_TABLE,
|
|
127
|
+
}),
|
|
128
|
+
).toBe('live')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('derives never-fires when no single route mounts both ends', () => {
|
|
132
|
+
expect(
|
|
133
|
+
deriveWireState(directWire, {
|
|
134
|
+
manifests: STRING_MANIFESTS,
|
|
135
|
+
table: SPLIT_TABLE,
|
|
136
|
+
}),
|
|
137
|
+
).toBe('never-fires')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('derives draft when a direct wire pairs mismatched types', () => {
|
|
141
|
+
const mismatched: Wire = { ...directWire, fieldType: 'number' }
|
|
142
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
143
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'number' } })),
|
|
144
|
+
targetTag(attribute('region', 'string')),
|
|
145
|
+
]
|
|
146
|
+
expect(
|
|
147
|
+
deriveWireState(mismatched, { manifests, table: SHARED_TABLE }),
|
|
148
|
+
).toBe('draft')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('derives draft when the declared inputType disagrees with the target attribute type', () => {
|
|
152
|
+
// fieldType === inputType, so the transform reconciles — ONLY the
|
|
153
|
+
// target-type conjunct can catch this wire writing a string into a
|
|
154
|
+
// number-typed attribute.
|
|
155
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
156
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'string' } })),
|
|
157
|
+
targetTag(attribute('region', 'number')),
|
|
158
|
+
]
|
|
159
|
+
expect(
|
|
160
|
+
deriveWireState(directWire, { manifests, table: SHARED_TABLE }),
|
|
161
|
+
).toBe('draft')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('derives draft when the declared fieldType contradicts the payload schema type', () => {
|
|
165
|
+
// Both declarations agree with each other AND with the target — the
|
|
166
|
+
// payload schema is the only witness that `symbol` is not a number.
|
|
167
|
+
const mistyped: Wire = {
|
|
168
|
+
...directWire,
|
|
169
|
+
fieldType: 'number',
|
|
170
|
+
inputType: 'number',
|
|
171
|
+
}
|
|
172
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
173
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'string' } })),
|
|
174
|
+
targetTag(attribute('region', 'number')),
|
|
175
|
+
]
|
|
176
|
+
expect(deriveWireState(mistyped, { manifests, table: SHARED_TABLE })).toBe(
|
|
177
|
+
'draft',
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('derives draft when the source tag has no manifest', () => {
|
|
182
|
+
expect(
|
|
183
|
+
deriveWireState(directWire, {
|
|
184
|
+
manifests: [targetTag(attribute('region', 'string'))],
|
|
185
|
+
table: SHARED_TABLE,
|
|
186
|
+
}),
|
|
187
|
+
).toBe('draft')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('derives draft when the event is missing from the source manifest', () => {
|
|
191
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
192
|
+
sourceTag(
|
|
193
|
+
manifestEvent('status-changed', { region: { type: 'string' } }),
|
|
194
|
+
),
|
|
195
|
+
targetTag(attribute('region', 'string')),
|
|
196
|
+
]
|
|
197
|
+
expect(
|
|
198
|
+
deriveWireState(directWire, { manifests, table: SHARED_TABLE }),
|
|
199
|
+
).toBe('draft')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("derives draft when the field is missing from the event's payload schema", () => {
|
|
203
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
204
|
+
sourceTag(manifestEvent('quote-changed', { cents: { type: 'number' } })),
|
|
205
|
+
targetTag(attribute('region', 'string')),
|
|
206
|
+
]
|
|
207
|
+
expect(
|
|
208
|
+
deriveWireState(directWire, { manifests, table: SHARED_TABLE }),
|
|
209
|
+
).toBe('draft')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('trusts the declared fieldType when the event carries no jsonSchema', () => {
|
|
213
|
+
// The emitter omits jsonSchema when it cannot render a payload schema —
|
|
214
|
+
// `ref` alone is still the contract, so the wire's own declaration stands.
|
|
215
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
216
|
+
sourceTag(manifestEvent('quote-changed')),
|
|
217
|
+
targetTag(attribute('region', 'string')),
|
|
218
|
+
]
|
|
219
|
+
expect(
|
|
220
|
+
deriveWireState(directWire, { manifests, table: SHARED_TABLE }),
|
|
221
|
+
).toBe('live')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('derives draft when the target input is missing from the manifest', () => {
|
|
225
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
226
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'string' } })),
|
|
227
|
+
targetTag(attribute('zone', 'string')),
|
|
228
|
+
]
|
|
229
|
+
expect(
|
|
230
|
+
deriveWireState(directWire, { manifests, table: SHARED_TABLE }),
|
|
231
|
+
).toBe('draft')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("derives draft when a condition's gate field is missing from the payload", () => {
|
|
235
|
+
const conditioned: Wire = {
|
|
236
|
+
...directWire,
|
|
237
|
+
transform: {
|
|
238
|
+
_tag: 'condition',
|
|
239
|
+
field: 'linked',
|
|
240
|
+
op: 'is',
|
|
241
|
+
value: 'true',
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
expect(
|
|
245
|
+
deriveWireState(conditioned, {
|
|
246
|
+
manifests: STRING_MANIFESTS,
|
|
247
|
+
table: SHARED_TABLE,
|
|
248
|
+
}),
|
|
249
|
+
).toBe('draft')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The emitter's REAL rendering of `S.Number` (verified against
|
|
254
|
+
* `SchemaRepresentation.toJsonSchemaDocument` at effect 4.0.0-rc.108): the
|
|
255
|
+
* extra branches are literal string encodings of NaN/Infinity, and each
|
|
256
|
+
* carries an `enum` key. A checker that counted their `type` would let
|
|
257
|
+
* 'string' corroborate every numeric field.
|
|
258
|
+
*/
|
|
259
|
+
const EMITTER_NUMBER_ANYOF = {
|
|
260
|
+
anyOf: [
|
|
261
|
+
{ type: 'number' },
|
|
262
|
+
{ type: 'string', enum: ['NaN'] },
|
|
263
|
+
{ type: 'string', enum: ['Infinity'] },
|
|
264
|
+
{ type: 'string', enum: ['-Infinity'] },
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
test('an anyOf-rendered number property corroborates a number declaration', () => {
|
|
269
|
+
const numberWire: Wire = {
|
|
270
|
+
...directWire,
|
|
271
|
+
field: 'cents',
|
|
272
|
+
fieldType: 'number',
|
|
273
|
+
inputType: 'number',
|
|
274
|
+
}
|
|
275
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
276
|
+
sourceTag(
|
|
277
|
+
manifestEvent('quote-changed', { cents: EMITTER_NUMBER_ANYOF }),
|
|
278
|
+
),
|
|
279
|
+
targetTag(attribute('region', 'number')),
|
|
280
|
+
]
|
|
281
|
+
expect(
|
|
282
|
+
deriveWireState(numberWire, { manifests, table: SHARED_TABLE }),
|
|
283
|
+
).toBe('live')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('a string declaration on an anyOf-rendered number field derives draft', () => {
|
|
287
|
+
// The NaN/Infinity branches are encoding artifacts, not evidence the
|
|
288
|
+
// field carries strings — counting them derives this wire live when
|
|
289
|
+
// `encodeFieldValue('string', 12345)` is None on every event: the
|
|
290
|
+
// silently dead broker the derivation exists to prevent.
|
|
291
|
+
const mistyped: Wire = { ...directWire, field: 'cents' }
|
|
292
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
293
|
+
sourceTag(
|
|
294
|
+
manifestEvent('quote-changed', { cents: EMITTER_NUMBER_ANYOF }),
|
|
295
|
+
),
|
|
296
|
+
targetTag(attribute('region', 'string')),
|
|
297
|
+
]
|
|
298
|
+
expect(deriveWireState(mistyped, { manifests, table: SHARED_TABLE })).toBe(
|
|
299
|
+
'draft',
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('a lookup into a string input reconciles a number source', () => {
|
|
304
|
+
const lookup: Wire = {
|
|
305
|
+
...directWire,
|
|
306
|
+
field: 'cents',
|
|
307
|
+
fieldType: 'number',
|
|
308
|
+
transform: { _tag: 'lookup', rows: { '100': 'cheap' }, fallback: 'dear' },
|
|
309
|
+
}
|
|
310
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
311
|
+
sourceTag(manifestEvent('quote-changed', { cents: { type: 'number' } })),
|
|
312
|
+
targetTag(attribute('region', 'string')),
|
|
313
|
+
]
|
|
314
|
+
expect(deriveWireState(lookup, { manifests, table: SHARED_TABLE })).toBe(
|
|
315
|
+
'live',
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('a lookup into an enum input is live iff every row value and the fallback are declared members', () => {
|
|
320
|
+
const enumTarget = targetTag(
|
|
321
|
+
attribute('region', 'enum', ['us-east', 'eu-west', 'ap-south-1']),
|
|
322
|
+
)
|
|
323
|
+
const manifests: ReadonlyArray<ManifestTag> = [
|
|
324
|
+
sourceTag(manifestEvent('quote-changed', { symbol: { type: 'string' } })),
|
|
325
|
+
enumTarget,
|
|
326
|
+
]
|
|
327
|
+
const lookup: Wire = {
|
|
328
|
+
...directWire,
|
|
329
|
+
inputType: 'enum',
|
|
330
|
+
transform: {
|
|
331
|
+
_tag: 'lookup',
|
|
332
|
+
rows: { FOLD: 'us-east', EFCT: 'eu-west' },
|
|
333
|
+
fallback: 'us-east',
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
expect(deriveWireState(lookup, { manifests, table: SHARED_TABLE })).toBe(
|
|
337
|
+
'live',
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
const escapee: Wire = {
|
|
341
|
+
...lookup,
|
|
342
|
+
transform: {
|
|
343
|
+
_tag: 'lookup',
|
|
344
|
+
rows: { FOLD: 'us-east', EFCT: 'mars' },
|
|
345
|
+
fallback: 'us-east',
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
expect(deriveWireState(escapee, { manifests, table: SHARED_TABLE })).toBe(
|
|
349
|
+
'draft',
|
|
350
|
+
)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
/* ============================================================
|
|
355
|
+
topologyRouteTable
|
|
356
|
+
============================================================ */
|
|
357
|
+
|
|
358
|
+
const PRICE_ROUTE = route('/price', ['price-ticker'])
|
|
359
|
+
const FLEET_ROUTE = route('/fleet', ['fleet-health'])
|
|
360
|
+
|
|
361
|
+
const topologyWith = (overview: HostTopology['overview']): HostTopology => ({
|
|
362
|
+
host: { id: 'demo-host', label: 'Demo host', ownedInputs: [] },
|
|
363
|
+
routes: [PRICE_ROUTE, FLEET_ROUTE],
|
|
364
|
+
...(overview === undefined ? {} : { overview }),
|
|
365
|
+
wires: [],
|
|
366
|
+
watch: [],
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// The failure mode: two consumers (a host entry, the Wiring dot) building
|
|
370
|
+
// their tables differently would make the SAME wire derive live in one place
|
|
371
|
+
// and never-fires in the other. One helper, one derivation.
|
|
372
|
+
describe('topologyRouteTable', () => {
|
|
373
|
+
test('derives an aggregate overview fallback when the descriptor is present', () => {
|
|
374
|
+
const table = topologyRouteTable(
|
|
375
|
+
topologyWith({
|
|
376
|
+
path: '/',
|
|
377
|
+
label: 'Overview',
|
|
378
|
+
title: 'Everything',
|
|
379
|
+
leadingSectionIds: ['intro-section'],
|
|
380
|
+
}),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(table.fallback.path).toBe('/')
|
|
384
|
+
expect(table.fallback.sectionIds).toEqual([
|
|
385
|
+
'intro-section',
|
|
386
|
+
'price-ticker-section',
|
|
387
|
+
'fleet-health-section',
|
|
388
|
+
])
|
|
389
|
+
expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
|
|
390
|
+
'price-ticker',
|
|
391
|
+
'fleet-health',
|
|
392
|
+
])
|
|
393
|
+
// The derived overview leads the table, so `/` wins route matching.
|
|
394
|
+
expect(table.routes.map(entry => entry.path)).toEqual([
|
|
395
|
+
'/',
|
|
396
|
+
'/price',
|
|
397
|
+
'/fleet',
|
|
398
|
+
])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('falls back to the first route when there is no overview', () => {
|
|
402
|
+
const table = topologyRouteTable(topologyWith(undefined))
|
|
403
|
+
|
|
404
|
+
expect(table.fallback).toEqual(PRICE_ROUTE)
|
|
405
|
+
expect(table.routes.map(entry => entry.path)).toEqual(['/price', '/fleet'])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// The failure mode this Phase-5 fold prevents: the platform's workbench
|
|
409
|
+
// mounts moved into ONE group rule, so without the fold every table-derived
|
|
410
|
+
// join goes blind to them at once — `unroutedTags` throws at startup for
|
|
411
|
+
// tags the runtime does mount, and a wire whose two ends ride the rule
|
|
412
|
+
// derives `never-fires` on the Wiring screen while the broker fires it.
|
|
413
|
+
test('folds rule-carried placements into every matching route, ahead of its own mounts', () => {
|
|
414
|
+
const table = topologyRouteTable({
|
|
415
|
+
...topologyWith(undefined),
|
|
416
|
+
rules: [
|
|
417
|
+
{
|
|
418
|
+
id: 'ticker-pages',
|
|
419
|
+
kind: 'group',
|
|
420
|
+
label: 'Ticker pages',
|
|
421
|
+
paths: ['/price', '/fleet'],
|
|
422
|
+
placements: [{ tag: 'promo-banner', slotId: 'hero-slot' }],
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
table.routes.forEach(entry => {
|
|
428
|
+
expect(entry.mounts.map(mount => mount.tag)[0]).toBe('promo-banner')
|
|
429
|
+
})
|
|
430
|
+
// The fallback is a route too — a rule-carried tag must be findable there.
|
|
431
|
+
expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
|
|
432
|
+
'promo-banner',
|
|
433
|
+
'price-ticker',
|
|
434
|
+
])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('a wire whose two ends only share rule-carried mounts still derives live', () => {
|
|
438
|
+
const table = topologyRouteTable({
|
|
439
|
+
...topologyWith(undefined),
|
|
440
|
+
rules: [
|
|
441
|
+
{
|
|
442
|
+
id: 'both-ends',
|
|
443
|
+
kind: 'pattern',
|
|
444
|
+
label: 'Everywhere',
|
|
445
|
+
pattern: '/*',
|
|
446
|
+
placements: [
|
|
447
|
+
{ tag: 'price-ticker', slotId: 'price-ticker-slot' },
|
|
448
|
+
{ tag: 'fleet-health', slotId: 'fleet-health-slot' },
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
routes: [
|
|
453
|
+
{ ...PRICE_ROUTE, mounts: [] },
|
|
454
|
+
{ ...FLEET_ROUTE, mounts: [] },
|
|
455
|
+
],
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
expect(
|
|
459
|
+
deriveWireState(directWire, { manifests: STRING_MANIFESTS, table }),
|
|
460
|
+
).toBe('live')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('a rule that matches no route changes nothing', () => {
|
|
464
|
+
const table = topologyRouteTable({
|
|
465
|
+
...topologyWith(undefined),
|
|
466
|
+
rules: [
|
|
467
|
+
{
|
|
468
|
+
id: 'docs',
|
|
469
|
+
kind: 'dynamic',
|
|
470
|
+
label: 'Doc pages',
|
|
471
|
+
template: '/docs/:slug',
|
|
472
|
+
placements: [{ tag: 'promo-banner', slotId: 'hero-slot' }],
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
expect(table.routes).toEqual([PRICE_ROUTE, FLEET_ROUTE])
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
/* ============================================================
|
|
482
|
+
HostTopology — the schema at its boundary. The stated purpose of these
|
|
483
|
+
schemas is decoding `host-topology.json`, so a JSON round-trip is the
|
|
484
|
+
fixture, exactly as a host's entry will feed it.
|
|
485
|
+
============================================================ */
|
|
486
|
+
|
|
487
|
+
const decodeTopology = S.decodeUnknownOption(HostTopology)
|
|
488
|
+
|
|
489
|
+
const RAW_TOPOLOGY = {
|
|
490
|
+
host: {
|
|
491
|
+
id: 'demo-host',
|
|
492
|
+
label: 'Demo host',
|
|
493
|
+
ownedInputs: [{ name: 'activity log', type: 'append' }],
|
|
494
|
+
},
|
|
495
|
+
routes: [PRICE_ROUTE, FLEET_ROUTE],
|
|
496
|
+
overview: { path: '/', label: 'Overview', title: 'Everything' },
|
|
497
|
+
wires: [directWire],
|
|
498
|
+
watch: [{ event: 'quote-changed' }],
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The failure mode: a topology file the schema silently mangles or loosely
|
|
502
|
+
// admits — `routes` non-empty is load-bearing for `topologyRouteTable`'s
|
|
503
|
+
// assertion-free `routes[0]`, so an empty-routes document MUST be rejected
|
|
504
|
+
// at the decode, not discovered as undefined downstream.
|
|
505
|
+
describe('HostTopology', () => {
|
|
506
|
+
test('decodes a host-topology.json shaped document', () => {
|
|
507
|
+
const parsed: unknown = JSON.parse(JSON.stringify(RAW_TOPOLOGY))
|
|
508
|
+
const decoded = decodeTopology(parsed)
|
|
509
|
+
|
|
510
|
+
expect(
|
|
511
|
+
Option.map(
|
|
512
|
+
decoded,
|
|
513
|
+
topology => topologyRouteTable(topology).fallback.path,
|
|
514
|
+
),
|
|
515
|
+
).toEqual(Option.some('/'))
|
|
516
|
+
expect(Option.map(decoded, topology => topology.wires)).toEqual(
|
|
517
|
+
Option.some([directWire]),
|
|
518
|
+
)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test('rejects a document whose routes are empty', () => {
|
|
522
|
+
const parsed: unknown = JSON.parse(
|
|
523
|
+
JSON.stringify({ ...RAW_TOPOLOGY, routes: [] }),
|
|
524
|
+
)
|
|
525
|
+
expect(Option.isNone(decodeTopology(parsed))).toBe(true)
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
/* ============================================================
|
|
530
|
+
Phase 5 — the widened placement, rules, and the REAL topology files.
|
|
531
|
+
Work items 1–3 of microdots-platform-phase-5-pages-design.md; the
|
|
532
|
+
backward-compat pin is the phase's hard requirement.
|
|
533
|
+
============================================================ */
|
|
534
|
+
|
|
535
|
+
/** The shipped files themselves, from disk — not a copy that would keep
|
|
536
|
+
* passing after the real file breaks. */
|
|
537
|
+
const topologyOnDisk = (relativePath: string): unknown =>
|
|
538
|
+
JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'))
|
|
539
|
+
|
|
540
|
+
// The failure mode: the Phase-5 widening is only admissible because BOTH
|
|
541
|
+
// shipped host-topology.json files decode unchanged — a new field declared
|
|
542
|
+
// `optional` instead of `optionalKey`, or one that injects a default at
|
|
543
|
+
// decode, would change what every host mounts without either file being
|
|
544
|
+
// touched. `toEqual(Option.some(parsed))` pins the decode as the identity.
|
|
545
|
+
describe('the shipped host-topology.json files', () => {
|
|
546
|
+
test('the demo host topology still decodes, unchanged', () => {
|
|
547
|
+
const parsed = topologyOnDisk('../../../apps/host/host-topology.json')
|
|
548
|
+
expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('the platform topology still decodes, unchanged', () => {
|
|
552
|
+
const parsed = topologyOnDisk('../../../apps/platform/host-topology.json')
|
|
553
|
+
expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
const decodePlacement = S.decodeUnknownOption(TopologyPlacement)
|
|
558
|
+
const encodePlacement = S.encodeSync(TopologyPlacement)
|
|
559
|
+
|
|
560
|
+
/** Every Phase-5 field at once: id, values, condition, envs, span, order. */
|
|
561
|
+
const WIDENED_PLACEMENT = {
|
|
562
|
+
id: 'promo-hero',
|
|
563
|
+
tag: 'promo-banner',
|
|
564
|
+
slotId: 'hero-slot',
|
|
565
|
+
values: { variant: 'spring' },
|
|
566
|
+
condition: { who: 'visitor', locale: 'fr' },
|
|
567
|
+
envs: ['dev', 'preview'],
|
|
568
|
+
span: 8,
|
|
569
|
+
order: 2,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The failure mode: the Pages screen's write mode will eventually save these
|
|
573
|
+
// records back to host-topology.json — a field that decodes but encodes
|
|
574
|
+
// lossily (a dropped optional, an injected default) corrupts a topology file
|
|
575
|
+
// on first save. And `span` outside 12/8/6/4/3 must die at the decode, not
|
|
576
|
+
// render as a broken grid.
|
|
577
|
+
describe('TopologyPlacement — the Phase-5 widening', () => {
|
|
578
|
+
test('a fully widened placement round-trips decode → encode byte-identically', () => {
|
|
579
|
+
const parsed: unknown = JSON.parse(JSON.stringify(WIDENED_PLACEMENT))
|
|
580
|
+
expect(Option.map(decodePlacement(parsed), encodePlacement)).toEqual(
|
|
581
|
+
Option.some(parsed),
|
|
582
|
+
)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test('rejects a span outside the literal set', () => {
|
|
586
|
+
const parsed: unknown = JSON.parse(
|
|
587
|
+
JSON.stringify({ ...WIDENED_PLACEMENT, span: 5 }),
|
|
588
|
+
)
|
|
589
|
+
expect(Option.isNone(decodePlacement(parsed))).toBe(true)
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// The failure mode: `rules` and `slotManifest` are the Phase-5 keys the
|
|
594
|
+
// Pages screen and the resolution read — a topology carrying all three rule
|
|
595
|
+
// kinds that fails to decode is a platform host that never starts.
|
|
596
|
+
describe('HostTopology — the Phase-5 keys', () => {
|
|
597
|
+
test('decodes a topology carrying rules and a slot manifest, unchanged', () => {
|
|
598
|
+
const raw = {
|
|
599
|
+
...RAW_TOPOLOGY,
|
|
600
|
+
slotManifest: {
|
|
601
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '1.4.0' },
|
|
602
|
+
slots: [{ id: 'hero-slot', kind: 'band', row: 0 }],
|
|
603
|
+
},
|
|
604
|
+
rules: [
|
|
605
|
+
{
|
|
606
|
+
id: 'r1',
|
|
607
|
+
kind: 'pattern',
|
|
608
|
+
label: 'Everywhere',
|
|
609
|
+
pattern: '/*',
|
|
610
|
+
placements: [{ tag: 'promo-banner', slotId: 'hero-slot' }],
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
id: 'r2',
|
|
614
|
+
kind: 'group',
|
|
615
|
+
label: 'Workbench pages',
|
|
616
|
+
paths: ['/apps', '/builds', '/deploys'],
|
|
617
|
+
placements: [WIDENED_PLACEMENT],
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: 'r3',
|
|
621
|
+
kind: 'dynamic',
|
|
622
|
+
label: 'Doc pages',
|
|
623
|
+
template: '/docs/:slug',
|
|
624
|
+
placements: [],
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
}
|
|
628
|
+
const parsed: unknown = JSON.parse(JSON.stringify(raw))
|
|
629
|
+
expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
|
|
630
|
+
})
|
|
631
|
+
})
|