@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.
@@ -0,0 +1,593 @@
1
+ import { Option } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import type { ManifestAttribute, MicroDotManifest } from '@bespokeagentics/microdots-element'
5
+
6
+ import {
7
+ type PlacementCheckContext,
8
+ type PlacementCheckId,
9
+ type PlacementCheckReport,
10
+ checkPlacements,
11
+ } from './placementChecks.ts'
12
+ import { type ResolvedPlacement, resolvePlacements } from './rules.ts'
13
+ import type { HostSlotManifest } from './slots.ts'
14
+ import type { HostTopology } from './wire.ts'
15
+
16
+ /* ============================================================
17
+ Fixture builders. A resolved placement defaults to active/unconditioned
18
+ in the declared `hero-slot`; a manifest defaults to a 10 KB bundle with
19
+ no deployed URL and no attributes. Each test overrides exactly the axis
20
+ its check reads.
21
+ ============================================================ */
22
+
23
+ const KB = 1024
24
+
25
+ const placed = (
26
+ id: string,
27
+ tag: string,
28
+ slotId: string,
29
+ overrides: Partial<ResolvedPlacement> = {},
30
+ ): ResolvedPlacement => ({
31
+ id,
32
+ tag,
33
+ slotId,
34
+ source: { _tag: 'route' },
35
+ condition: { who: 'any', device: 'any', locale: 'any' },
36
+ envs: ['dev', 'preview', 'prod'],
37
+ values: {},
38
+ span: Option.none(),
39
+ order: Option.none(),
40
+ state: 'active',
41
+ overriddenBy: [],
42
+ ...overrides,
43
+ })
44
+
45
+ type ManifestOptions = {
46
+ readonly gzipBytes?: number
47
+ readonly deployedApiUrl?: string
48
+ readonly attributes?: ReadonlyArray<ManifestAttribute>
49
+ }
50
+
51
+ const manifestOf = (
52
+ name: string,
53
+ tags: ReadonlyArray<string>,
54
+ options: ManifestOptions = {},
55
+ ): MicroDotManifest => ({
56
+ name,
57
+ build: `${name}-build`,
58
+ bundle: {
59
+ file: `${name}.js`,
60
+ hashedFile: `${name}.abc12345.js`,
61
+ bytes: (options.gzipBytes ?? 10 * KB) * 3,
62
+ gzipBytes: options.gzipBytes ?? 10 * KB,
63
+ },
64
+ service: {
65
+ localPort: 3101,
66
+ ...(options.deployedApiUrl === undefined
67
+ ? {}
68
+ : { deployedApiUrl: options.deployedApiUrl }),
69
+ },
70
+ tags: tags.map(tag => ({
71
+ tag,
72
+ attributes: options.attributes ?? [],
73
+ events: [],
74
+ })),
75
+ })
76
+
77
+ const requiredAttribute = (
78
+ name: string,
79
+ ownership: ManifestAttribute['ownership'],
80
+ ): ManifestAttribute => ({
81
+ name,
82
+ type: 'string',
83
+ required: true,
84
+ live: true,
85
+ ownership,
86
+ })
87
+
88
+ const SLOT_MANIFEST: HostSlotManifest = {
89
+ theme: { name: '@bespokeagentics/microdots-theme', version: '1.4.0' },
90
+ slots: [
91
+ { id: 'hero-slot', kind: 'band', row: 0 },
92
+ { id: 'side-slot', kind: 'rail', row: 1, width: '280px' },
93
+ ],
94
+ }
95
+
96
+ const contextOf = (
97
+ manifests: ReadonlyArray<MicroDotManifest>,
98
+ overrides: Partial<PlacementCheckContext> = {},
99
+ ): PlacementCheckContext => ({
100
+ manifests,
101
+ slotManifest: SLOT_MANIFEST,
102
+ env: 'dev',
103
+ ...overrides,
104
+ })
105
+
106
+ const issuesFor = (report: PlacementCheckReport, check: PlacementCheckId) =>
107
+ report.issues.filter(issue => issue.check === check)
108
+
109
+ // The failure mode: a dot whose build stops at dev, placed on a preview or
110
+ // prod route, resolves to nothing and the slot collapses — and nothing says
111
+ // so until a user hits the page. Reachability is the recorded approximation:
112
+ // dev always reachable, preview/prod iff the manifest carries
113
+ // `service.deployedApiUrl` (refined by Phase 7).
114
+ describe('checkPlacements — check 1: not deployed to this environment', () => {
115
+ test('fires an error in preview for a placement whose manifest has no deployed URL', () => {
116
+ const report = checkPlacements(
117
+ [placed('p1', 'workbench-brief', 'hero-slot')],
118
+ contextOf([manifestOf('workbench', ['workbench-brief'])], {
119
+ env: 'preview',
120
+ }),
121
+ )
122
+ expect(issuesFor(report, 'not-deployed')).toEqual([
123
+ {
124
+ check: 'not-deployed',
125
+ severity: 'error',
126
+ placementId: 'p1',
127
+ consequence: expect.stringContaining(
128
+ 'Build workbench-build stops at dev',
129
+ ),
130
+ action: 'Open build workbench-build',
131
+ },
132
+ ])
133
+ })
134
+
135
+ test('does not fire in dev — the local service exists by construction', () => {
136
+ const report = checkPlacements(
137
+ [placed('p1', 'workbench-brief', 'hero-slot')],
138
+ contextOf([manifestOf('workbench', ['workbench-brief'])]),
139
+ )
140
+ expect(report.issues).toEqual([])
141
+ expect(report.unverifiable).toEqual([])
142
+ })
143
+
144
+ test('does not fire in prod when the manifest carries a deployed URL', () => {
145
+ const report = checkPlacements(
146
+ [placed('p1', 'workbench-brief', 'hero-slot')],
147
+ contextOf(
148
+ [
149
+ manifestOf('workbench', ['workbench-brief'], {
150
+ deployedApiUrl: 'https://workbench.example.workers.dev',
151
+ }),
152
+ ],
153
+ { env: 'prod' },
154
+ ),
155
+ )
156
+ expect(issuesFor(report, 'not-deployed')).toEqual([])
157
+ })
158
+ })
159
+
160
+ // The failure mode: a placement whose slot the current theme no longer
161
+ // declares renders nowhere, and without the manifest's `theme@version` in
162
+ // the message nobody can say the spec's sentence — "it was valid in an
163
+ // earlier theme version" — about why it used to work.
164
+ describe('checkPlacements — check 2: slot not in the theme', () => {
165
+ test('fires an error naming theme@version for a slot the manifest does not declare', () => {
166
+ const report = checkPlacements(
167
+ [placed('p1', 'promo-banner', 'ghost-slot')],
168
+ contextOf([manifestOf('promo', ['promo-banner'])]),
169
+ )
170
+ expect(issuesFor(report, 'slot-not-in-theme')).toEqual([
171
+ {
172
+ check: 'slot-not-in-theme',
173
+ severity: 'error',
174
+ placementId: 'p1',
175
+ consequence: expect.stringContaining(
176
+ 'Slot ghost-slot is not in @bespokeagentics/microdots-theme@1.4.0',
177
+ ),
178
+ action: 'Move it to a declared slot',
179
+ },
180
+ ])
181
+ })
182
+
183
+ test('does not fire for a declared slot', () => {
184
+ const report = checkPlacements(
185
+ [placed('p1', 'promo-banner', 'side-slot')],
186
+ contextOf([manifestOf('promo', ['promo-banner'])]),
187
+ )
188
+ expect(issuesFor(report, 'slot-not-in-theme')).toEqual([])
189
+ })
190
+
191
+ test('degrades honestly when the topology has no slot manifest — skipped, not passed', () => {
192
+ // No `slotManifest` key at all (a Phase-4 topology). Under
193
+ // exactOptionalPropertyTypes the absence is the fixture, so the context
194
+ // is built literally rather than through `contextOf`.
195
+ const report = checkPlacements(
196
+ [placed('p1', 'promo-banner', 'hero-slot')],
197
+ {
198
+ manifests: [manifestOf('promo', ['promo-banner'])],
199
+ env: 'dev',
200
+ },
201
+ )
202
+ expect(issuesFor(report, 'slot-not-in-theme')).toEqual([])
203
+ expect(report.unverifiable).toEqual([
204
+ {
205
+ check: 'slot-not-in-theme',
206
+ reason: expect.stringContaining('slot checks skipped, not passed'),
207
+ },
208
+ ])
209
+ })
210
+ })
211
+
212
+ // The failure mode: a manifest-required attribute nobody sets means the
213
+ // element mounts and errors on first render — in production, where the
214
+ // missing value was invisible in every local run that happened to set it.
215
+ describe('checkPlacements — check 3: required attribute unset', () => {
216
+ test('warns when a required dot-owned attribute has no supplied value', () => {
217
+ const report = checkPlacements(
218
+ [placed('p1', 'price-ticker', 'hero-slot')],
219
+ contextOf([
220
+ manifestOf('price', ['price-ticker'], {
221
+ attributes: [requiredAttribute('symbol', 'dot')],
222
+ }),
223
+ ]),
224
+ )
225
+ expect(issuesFor(report, 'required-attribute-unset')).toEqual([
226
+ {
227
+ check: 'required-attribute-unset',
228
+ severity: 'warning',
229
+ placementId: 'p1',
230
+ consequence: expect.stringContaining('symbol is unset'),
231
+ action: 'Set a value above',
232
+ },
233
+ ])
234
+ })
235
+
236
+ test('does not warn when the resolved placement supplies the value', () => {
237
+ const report = checkPlacements(
238
+ [
239
+ placed('p1', 'price-ticker', 'hero-slot', {
240
+ values: { symbol: 'FOLD' },
241
+ }),
242
+ ],
243
+ contextOf([
244
+ manifestOf('price', ['price-ticker'], {
245
+ attributes: [requiredAttribute('symbol', 'dot')],
246
+ }),
247
+ ]),
248
+ )
249
+ expect(issuesFor(report, 'required-attribute-unset')).toEqual([])
250
+ })
251
+
252
+ test('exempts environment- and host-owned attributes — the loader and the host supply those', () => {
253
+ const report = checkPlacements(
254
+ [placed('p1', 'price-ticker', 'hero-slot')],
255
+ contextOf([
256
+ manifestOf('price', ['price-ticker'], {
257
+ attributes: [
258
+ requiredAttribute('api-url', 'environment'),
259
+ requiredAttribute('interview-id', 'host-input'),
260
+ ],
261
+ }),
262
+ ]),
263
+ )
264
+ expect(issuesFor(report, 'required-attribute-unset')).toEqual([])
265
+ })
266
+ })
267
+
268
+ // The failure mode: the same dot placed twice with overlapping conditions is
269
+ // two live instances side by side (check 4) — while disjoint conditions are
270
+ // legitimate variant work that must NOT read as a mistake (check 7). Both are
271
+ // computed over the RESOLUTION's output, so reading B's cross-level twins
272
+ // fire too.
273
+ describe('checkPlacements — checks 4 and 7: the same dot twice', () => {
274
+ const PROMO_MANIFESTS = [manifestOf('promo', ['promo-banner'])]
275
+
276
+ test('warns on both members when conditions overlap — two instances run side by side', () => {
277
+ const report = checkPlacements(
278
+ [
279
+ placed('p1', 'promo-banner', 'hero-slot'),
280
+ placed('p2', 'promo-banner', 'side-slot', {
281
+ condition: { who: 'any', device: 'mobile', locale: 'any' },
282
+ }),
283
+ ],
284
+ contextOf(PROMO_MANIFESTS),
285
+ )
286
+ expect(issuesFor(report, 'mounts-twice-overlapping')).toEqual([
287
+ {
288
+ check: 'mounts-twice-overlapping',
289
+ severity: 'warning',
290
+ placementId: 'p1',
291
+ consequence: expect.stringContaining('also placed in side-slot'),
292
+ action: 'Narrow one condition',
293
+ },
294
+ {
295
+ check: 'mounts-twice-overlapping',
296
+ severity: 'warning',
297
+ placementId: 'p2',
298
+ consequence: expect.stringContaining('also placed in hero-slot'),
299
+ action: 'Narrow one condition',
300
+ },
301
+ ])
302
+ })
303
+
304
+ test('does not warn when the twin conditions are disjoint', () => {
305
+ const report = checkPlacements(
306
+ [
307
+ placed('p1', 'promo-banner', 'hero-slot', {
308
+ condition: { who: 'any', device: 'any', locale: 'fr' },
309
+ }),
310
+ placed('p2', 'promo-banner', 'side-slot', {
311
+ condition: { who: 'any', device: 'any', locale: 'en' },
312
+ }),
313
+ ],
314
+ contextOf(PROMO_MANIFESTS),
315
+ )
316
+ expect(issuesFor(report, 'mounts-twice-overlapping')).toEqual([])
317
+ })
318
+
319
+ test('notes disjoint twins as legitimate variant work', () => {
320
+ const report = checkPlacements(
321
+ [
322
+ placed('p1', 'promo-banner', 'hero-slot', {
323
+ condition: { who: 'any', device: 'any', locale: 'fr' },
324
+ }),
325
+ placed('p2', 'promo-banner', 'side-slot', {
326
+ condition: { who: 'any', device: 'any', locale: 'en' },
327
+ }),
328
+ ],
329
+ contextOf(PROMO_MANIFESTS),
330
+ )
331
+ expect(issuesFor(report, 'placed-twice-disjoint')).toEqual([
332
+ {
333
+ check: 'placed-twice-disjoint',
334
+ severity: 'note',
335
+ placementId: 'p1',
336
+ consequence: expect.stringContaining('legitimate variant work'),
337
+ action: 'No action needed',
338
+ },
339
+ {
340
+ check: 'placed-twice-disjoint',
341
+ severity: 'note',
342
+ placementId: 'p2',
343
+ consequence: expect.stringContaining('legitimate variant work'),
344
+ action: 'No action needed',
345
+ },
346
+ ])
347
+ })
348
+
349
+ test('raises no twin issues for a lone placement', () => {
350
+ const report = checkPlacements(
351
+ [placed('p1', 'promo-banner', 'hero-slot')],
352
+ contextOf(PROMO_MANIFESTS),
353
+ )
354
+ expect(issuesFor(report, 'mounts-twice-overlapping')).toEqual([])
355
+ expect(issuesFor(report, 'placed-twice-disjoint')).toEqual([])
356
+ })
357
+ })
358
+
359
+ // The failure mode: the fixture's per-placement sum double-counts a shared
360
+ // bundle — the workbench's three tags are ONE fetch, because `loadMicroDot`
361
+ // dedupes it — so a naive check 6 cries over-budget on routes that are fine,
362
+ // and the warning gets ignored on the route that is not.
363
+ describe('checkPlacements — check 6: over the page-weight budget', () => {
364
+ test('sums each distinct bundle once — a shared bundle mounted twice does not double-count', () => {
365
+ // Distinct sum: 100 + 70 = 170 KB ≤ 180. The per-placement sum would be
366
+ // 100 + 100 + 70 = 270 KB and fire — this fixture is the double-count
367
+ // kill.
368
+ const report = checkPlacements(
369
+ [
370
+ placed('p1', 'workbench-app-list', 'hero-slot'),
371
+ placed('p2', 'workbench-brief', 'side-slot'),
372
+ placed('p3', 'wiring-canvas', 'side-slot'),
373
+ ],
374
+ contextOf([
375
+ manifestOf('workbench', ['workbench-app-list', 'workbench-brief'], {
376
+ gzipBytes: 100 * KB,
377
+ }),
378
+ manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 70 * KB }),
379
+ ]),
380
+ )
381
+ expect(issuesFor(report, 'over-weight-budget')).toEqual([])
382
+ })
383
+
384
+ test('warns once over budget, naming the total and the largest bundle', () => {
385
+ const report = checkPlacements(
386
+ [
387
+ placed('p1', 'workbench-app-list', 'hero-slot'),
388
+ placed('p2', 'workbench-brief', 'side-slot'),
389
+ placed('p3', 'wiring-canvas', 'side-slot'),
390
+ ],
391
+ contextOf([
392
+ manifestOf('workbench', ['workbench-app-list', 'workbench-brief'], {
393
+ gzipBytes: 100 * KB,
394
+ }),
395
+ manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 90 * KB }),
396
+ ]),
397
+ )
398
+ expect(issuesFor(report, 'over-weight-budget')).toEqual([
399
+ {
400
+ check: 'over-weight-budget',
401
+ severity: 'warning',
402
+ placementId: 'p1',
403
+ consequence:
404
+ 'Total bundle weight of everything mounting on this route is 190 KB gzipped against a 180 KB budget. The largest is workbench at 100 KB.',
405
+ action: 'Remove or split workbench, or raise the budget',
406
+ },
407
+ ])
408
+ })
409
+ })
410
+
411
+ // The failure mode: an unverifiable entry is a fact about a TAG — "no
412
+ // manifest was emitted for it" — not about a placement. Emitted per placement
413
+ // it repeats itself: three placements of one manifest-less tag on a route
414
+ // produced three byte-identical lines from checks 1 and 3 while check 6, which
415
+ // deduped from the start, produced one. A panel that says the same thing three
416
+ // times reads as three separate gaps and buries the checks that only said
417
+ // theirs once.
418
+ describe('checkPlacements — unverifiable checks are deduped by tag', () => {
419
+ test('three placements of one manifest-less tag report each check ONCE', () => {
420
+ const report = checkPlacements(
421
+ [
422
+ placed('p1', 'ghost-dot', 'hero-slot'),
423
+ placed('p2', 'ghost-dot', 'side-slot'),
424
+ placed('p3', 'ghost-dot', 'hero-slot'),
425
+ ],
426
+ // `preview`, so check 1 actually runs — in dev it short-circuits before
427
+ // it can report anything.
428
+ contextOf([], { env: 'preview' }),
429
+ )
430
+
431
+ expect(report.unverifiable.map(entry => entry.check)).toEqual([
432
+ 'not-deployed',
433
+ 'required-attribute-unset',
434
+ 'over-weight-budget',
435
+ ])
436
+ expect(
437
+ new Set(
438
+ report.unverifiable.map(entry => `${entry.check}|${entry.reason}`),
439
+ ).size,
440
+ ).toBe(report.unverifiable.length)
441
+ })
442
+
443
+ test('two DIFFERENT manifest-less tags are still two facts, not one', () => {
444
+ const report = checkPlacements(
445
+ [
446
+ placed('p1', 'ghost-dot', 'hero-slot'),
447
+ placed('p2', 'ghost-dot', 'side-slot'),
448
+ placed('p3', 'other-ghost', 'side-slot'),
449
+ ],
450
+ contextOf([], { env: 'preview' }),
451
+ )
452
+
453
+ // Deduping must not collapse distinct tags: checks 1 and 3 name one tag
454
+ // per sentence, so each unverified tag keeps its own line.
455
+ expect(
456
+ report.unverifiable
457
+ .filter(entry => entry.check === 'required-attribute-unset')
458
+ .map(entry => entry.reason),
459
+ ).toEqual([
460
+ 'no manifest for ghost-dot — its required attributes cannot be verified',
461
+ 'no manifest for other-ghost — its required attributes cannot be verified',
462
+ ])
463
+ })
464
+ })
465
+
466
+ // The failure mode: `overridePair` decides a same-tag pair is already resolved
467
+ // by the contest — and therefore raises no duplicate warning — by matching
468
+ // `winnerId === other.id`. When two placements share an id that match fires
469
+ // for a pair that never contested, and a REAL check-4 warning disappears. The
470
+ // resolution's id uniqueness is the only thing standing between a duplicated
471
+ // `id` in a topology file and a silently unchecked page.
472
+ describe('checkPlacements — over a resolution whose ids collide in the source', () => {
473
+ const COLLIDING_TOPOLOGY: HostTopology = {
474
+ host: { id: 'test-host', label: 'Test host', ownedInputs: [] },
475
+ routes: [
476
+ {
477
+ path: '/home',
478
+ label: '/home',
479
+ title: '/home',
480
+ sectionIds: [],
481
+ mounts: [
482
+ // Partially overrides the rule's placement (same slot, narrower
483
+ // condition) …
484
+ {
485
+ id: 'dup',
486
+ tag: 'promo-banner',
487
+ slotId: 'hero-slot',
488
+ condition: { locale: 'fr' },
489
+ },
490
+ // … while this one is a genuine unresolved twin of it: same tag,
491
+ // DIFFERENT slot, so no contest — check 4's exact subject.
492
+ { id: 'dup', tag: 'promo-banner', slotId: 'side-slot' },
493
+ ],
494
+ },
495
+ ],
496
+ rules: [
497
+ {
498
+ id: 'r1',
499
+ kind: 'pattern',
500
+ label: 'Rule r1',
501
+ pattern: '/*',
502
+ placements: [{ id: 'dup', tag: 'promo-banner', slotId: 'hero-slot' }],
503
+ },
504
+ ],
505
+ wires: [],
506
+ watch: [],
507
+ }
508
+
509
+ test('the rule-carried placement still gets its duplicate warning', () => {
510
+ const resolved = resolvePlacements(COLLIDING_TOPOLOGY, '/home', 'dev')
511
+ const inherited = resolved.find(item => item.source._tag === 'rule')
512
+ if (inherited === undefined) {
513
+ throw new Error('the rule placement did not resolve')
514
+ }
515
+ expect(inherited.state).toBe('partially-overridden')
516
+
517
+ const report = checkPlacements(
518
+ resolved,
519
+ contextOf([manifestOf('promo', ['promo-banner'])]),
520
+ )
521
+
522
+ // All three still render for somebody, and each has an un-contested twin,
523
+ // so each raises the warning. With colliding ids the inherited one raises
524
+ // nothing: its `overriddenBy.winnerId` matches the id of the placement it
525
+ // never contested, and `overridePair` reads that as already-resolved.
526
+ const flagged = issuesFor(report, 'mounts-twice-overlapping').map(
527
+ issue => issue.placementId,
528
+ )
529
+ expect(flagged).toContain(inherited.id)
530
+ expect(flagged.length).toBe(3)
531
+ expect(new Set(flagged).size).toBe(3)
532
+ })
533
+ })
534
+
535
+ // The failure mode: reading B's qualifier. A placement overridden ENTIRELY
536
+ // renders for nobody, so flagging its problems is noise that trains people to
537
+ // ignore the panel — while a PARTIALLY overridden one still renders for
538
+ // somebody, so silencing it hides a real break.
539
+ describe('checkPlacements — the reading-B override qualifier', () => {
540
+ test('a fully overridden placement raises no issues however broken it is', () => {
541
+ // The loser sits in an undeclared slot AND omits a required attribute —
542
+ // two firing conditions, zero issues, because it renders for nobody.
543
+ const report = checkPlacements(
544
+ [
545
+ placed('loser', 'price-ticker', 'ghost-slot', {
546
+ state: 'overridden',
547
+ overriddenBy: [
548
+ {
549
+ by: { _tag: 'route' },
550
+ winnerId: 'winner',
551
+ where: { who: 'any', device: 'any', locale: 'any' },
552
+ entire: true,
553
+ },
554
+ ],
555
+ }),
556
+ placed('winner', 'price-ticker', 'hero-slot', {
557
+ values: { symbol: 'FOLD' },
558
+ }),
559
+ ],
560
+ contextOf([
561
+ manifestOf('price', ['price-ticker'], {
562
+ attributes: [requiredAttribute('symbol', 'dot')],
563
+ }),
564
+ ]),
565
+ )
566
+ expect(report.issues).toEqual([])
567
+ })
568
+
569
+ test('a partially overridden placement is still checked — it still renders for somebody', () => {
570
+ const report = checkPlacements(
571
+ [
572
+ placed('loser', 'price-ticker', 'ghost-slot', {
573
+ state: 'partially-overridden',
574
+ overriddenBy: [
575
+ {
576
+ by: { _tag: 'route' },
577
+ winnerId: 'winner',
578
+ where: { who: 'any', device: 'any', locale: 'fr' },
579
+ entire: false,
580
+ },
581
+ ],
582
+ }),
583
+ placed('winner', 'price-ticker', 'hero-slot', {
584
+ condition: { who: 'any', device: 'any', locale: 'fr' },
585
+ }),
586
+ ],
587
+ contextOf([manifestOf('price', ['price-ticker'])]),
588
+ )
589
+ expect(
590
+ issuesFor(report, 'slot-not-in-theme').map(issue => issue.placementId),
591
+ ).toEqual(['loser'])
592
+ })
593
+ })