@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
|
@@ -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
|
+
})
|