@wibly/internal-manifest 0.1.1
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/CHANGELOG.md +6 -0
- package/package.json +25 -0
- package/src/content-rating.test.ts +26 -0
- package/src/content-rating.ts +51 -0
- package/src/fixtures.ts +229 -0
- package/src/index.ts +126 -0
- package/src/manifest.test.ts +351 -0
- package/src/manifest.ts +607 -0
- package/src/phase.test.ts +227 -0
- package/src/phase.ts +304 -0
- package/src/portal-display.test.ts +54 -0
- package/src/portal-display.ts +40 -0
- package/src/validate.test.ts +310 -0
- package/src/validate.ts +385 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { cloneFixture, validManifestFixture } from './fixtures.js';
|
|
4
|
+
import {
|
|
5
|
+
ContentRatingSchema,
|
|
6
|
+
InferenceEnvelopeSchema,
|
|
7
|
+
ManifestIdSchema,
|
|
8
|
+
ManifestSchema,
|
|
9
|
+
PersonaBindingSchema,
|
|
10
|
+
PersonaIdSchema,
|
|
11
|
+
PortalMetadataSchema,
|
|
12
|
+
PromptSlotsSchema,
|
|
13
|
+
PromptSlotValueSchema,
|
|
14
|
+
} from './manifest.js';
|
|
15
|
+
|
|
16
|
+
describe('ManifestIdSchema / PersonaIdSchema', () => {
|
|
17
|
+
it('accepts a well-formed exp_ id', () => {
|
|
18
|
+
expect(ManifestIdSchema.safeParse('exp_abc123_-').success).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects a bare nanoid (missing prefix)', () => {
|
|
22
|
+
expect(ManifestIdSchema.safeParse('abc123').success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects the wrong prefix', () => {
|
|
26
|
+
expect(ManifestIdSchema.safeParse('tnt_abc123').success).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts a per_ persona id', () => {
|
|
30
|
+
expect(PersonaIdSchema.safeParse('per_abc123').success).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects a non-per_ persona id', () => {
|
|
34
|
+
expect(PersonaIdSchema.safeParse('plr_abc123').success).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('PersonaBindingSchema', () => {
|
|
39
|
+
it('accepts a well-formed binding', () => {
|
|
40
|
+
expect(
|
|
41
|
+
PersonaBindingSchema.safeParse({ role: 'host', personaId: 'per_abc' })
|
|
42
|
+
.success,
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects an empty role', () => {
|
|
47
|
+
expect(
|
|
48
|
+
PersonaBindingSchema.safeParse({ role: '', personaId: 'per_abc' })
|
|
49
|
+
.success,
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a malformed personaId', () => {
|
|
54
|
+
expect(
|
|
55
|
+
PersonaBindingSchema.safeParse({ role: 'host', personaId: 'abc' })
|
|
56
|
+
.success,
|
|
57
|
+
).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('InferenceEnvelopeSchema', () => {
|
|
62
|
+
const baseline = {
|
|
63
|
+
maxLlmCallsPerSession: 10,
|
|
64
|
+
maxTokensInPerCall: 1024,
|
|
65
|
+
maxTokensOutPerCall: 512,
|
|
66
|
+
maxTtsSecondsPerSession: 60,
|
|
67
|
+
qualityTiers: ['standard'],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
it('accepts a complete envelope', () => {
|
|
71
|
+
expect(InferenceEnvelopeSchema.safeParse(baseline).success).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects a zero call budget (the inference envelope is non-zero rule)', () => {
|
|
75
|
+
expect(
|
|
76
|
+
InferenceEnvelopeSchema.safeParse({ ...baseline, maxLlmCallsPerSession: 0 })
|
|
77
|
+
.success,
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects a zero token-in cap', () => {
|
|
82
|
+
expect(
|
|
83
|
+
InferenceEnvelopeSchema.safeParse({ ...baseline, maxTokensInPerCall: 0 })
|
|
84
|
+
.success,
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('allows zero TTS seconds (no-TTS Experience)', () => {
|
|
89
|
+
expect(
|
|
90
|
+
InferenceEnvelopeSchema.safeParse({
|
|
91
|
+
...baseline,
|
|
92
|
+
maxTtsSecondsPerSession: 0,
|
|
93
|
+
}).success,
|
|
94
|
+
).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects an empty quality-tier set', () => {
|
|
98
|
+
expect(
|
|
99
|
+
InferenceEnvelopeSchema.safeParse({ ...baseline, qualityTiers: [] })
|
|
100
|
+
.success,
|
|
101
|
+
).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects an unknown quality tier', () => {
|
|
105
|
+
expect(
|
|
106
|
+
InferenceEnvelopeSchema.safeParse({
|
|
107
|
+
...baseline,
|
|
108
|
+
qualityTiers: ['ultra'],
|
|
109
|
+
}).success,
|
|
110
|
+
).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('PromptSlotValueSchema / PromptSlotsSchema', () => {
|
|
115
|
+
it('accepts a literal string slot', () => {
|
|
116
|
+
expect(PromptSlotValueSchema.safeParse('hi there').success).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('accepts a templated slot with vars', () => {
|
|
120
|
+
expect(
|
|
121
|
+
PromptSlotValueSchema.safeParse({
|
|
122
|
+
template: 'You have {count} cards',
|
|
123
|
+
vars: ['count'],
|
|
124
|
+
}).success,
|
|
125
|
+
).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('accepts a templated slot without vars', () => {
|
|
129
|
+
expect(
|
|
130
|
+
PromptSlotValueSchema.safeParse({ template: 'Welcome' }).success,
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects a templated slot with empty vars array (use a literal instead)', () => {
|
|
135
|
+
expect(
|
|
136
|
+
PromptSlotValueSchema.safeParse({ template: 'Welcome', vars: [] })
|
|
137
|
+
.success,
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('rejects a callTypes key outside the known callKind set', () => {
|
|
142
|
+
expect(
|
|
143
|
+
PromptSlotsSchema.safeParse({
|
|
144
|
+
experienceSystem: 'sys',
|
|
145
|
+
callTypes: { unknown_kind: 'hello' },
|
|
146
|
+
}).success,
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('ContentRatingSchema', () => {
|
|
152
|
+
it('accepts none / consumer', () => {
|
|
153
|
+
expect(
|
|
154
|
+
ContentRatingSchema.safeParse({
|
|
155
|
+
tier: 'none',
|
|
156
|
+
audiences: ['consumer'],
|
|
157
|
+
}).success,
|
|
158
|
+
).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects an empty audience set', () => {
|
|
162
|
+
expect(
|
|
163
|
+
ContentRatingSchema.safeParse({ tier: 'none', audiences: [] }).success,
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects an unknown tier', () => {
|
|
168
|
+
expect(
|
|
169
|
+
ContentRatingSchema.safeParse({ tier: 'r-rated', audiences: ['consumer'] })
|
|
170
|
+
.success,
|
|
171
|
+
).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('PortalMetadataSchema (chunk B11)', () => {
|
|
176
|
+
const baseline = {
|
|
177
|
+
heroImageUrl: 'https://assets.wibly.example/hero.png',
|
|
178
|
+
sampleRoundDescription: 'One brief paragraph.',
|
|
179
|
+
occasionTags: ['party'],
|
|
180
|
+
} as const;
|
|
181
|
+
|
|
182
|
+
it('accepts the baseline (required-only) shape', () => {
|
|
183
|
+
expect(PortalMetadataSchema.safeParse(baseline).success).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts gameplay images and video', () => {
|
|
187
|
+
expect(
|
|
188
|
+
PortalMetadataSchema.safeParse({
|
|
189
|
+
...baseline,
|
|
190
|
+
gameplayImages: [
|
|
191
|
+
{
|
|
192
|
+
title: 'Round one',
|
|
193
|
+
imageUrl: 'https://assets.wibly.example/round1.png',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
gameplayVideo: {
|
|
197
|
+
title: 'Trailer',
|
|
198
|
+
videoUrl: 'https://assets.wibly.example/trailer.mp4',
|
|
199
|
+
},
|
|
200
|
+
personaPreviewAudioUrl: 'https://assets.wibly.example/persona.mp3',
|
|
201
|
+
}).success,
|
|
202
|
+
).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('rejects a missing heroImageUrl', () => {
|
|
206
|
+
const fixture: Record<string, unknown> = { ...baseline };
|
|
207
|
+
delete fixture.heroImageUrl;
|
|
208
|
+
expect(PortalMetadataSchema.safeParse(fixture).success).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects a heroImageUrl that is not a URL', () => {
|
|
212
|
+
expect(
|
|
213
|
+
PortalMetadataSchema.safeParse({ ...baseline, heroImageUrl: 'not a url' })
|
|
214
|
+
.success,
|
|
215
|
+
).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('rejects a missing sampleRoundDescription', () => {
|
|
219
|
+
const fixture: Record<string, unknown> = { ...baseline };
|
|
220
|
+
delete fixture.sampleRoundDescription;
|
|
221
|
+
expect(PortalMetadataSchema.safeParse(fixture).success).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('rejects an empty sampleRoundDescription', () => {
|
|
225
|
+
expect(
|
|
226
|
+
PortalMetadataSchema.safeParse({ ...baseline, sampleRoundDescription: '' })
|
|
227
|
+
.success,
|
|
228
|
+
).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('rejects an empty occasionTags array', () => {
|
|
232
|
+
expect(
|
|
233
|
+
PortalMetadataSchema.safeParse({ ...baseline, occasionTags: [] }).success,
|
|
234
|
+
).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('rejects an unknown occasion tag', () => {
|
|
238
|
+
expect(
|
|
239
|
+
PortalMetadataSchema.safeParse({
|
|
240
|
+
...baseline,
|
|
241
|
+
occasionTags: ['hen_party'],
|
|
242
|
+
}).success,
|
|
243
|
+
).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('ManifestSchema — required fields', () => {
|
|
248
|
+
it('accepts the baseline fixture', () => {
|
|
249
|
+
const result = ManifestSchema.safeParse(validManifestFixture);
|
|
250
|
+
expect(result.success).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// One test per top-level required field, removed in isolation.
|
|
254
|
+
const REQUIRED_FIELDS = [
|
|
255
|
+
'id',
|
|
256
|
+
'version',
|
|
257
|
+
'name',
|
|
258
|
+
'description',
|
|
259
|
+
'creator',
|
|
260
|
+
'createdAt',
|
|
261
|
+
'personaBindings',
|
|
262
|
+
'inferenceEnvelope',
|
|
263
|
+
'stateSchema',
|
|
264
|
+
'workflow',
|
|
265
|
+
'concurrentOpportunities',
|
|
266
|
+
'scoring',
|
|
267
|
+
'lifecyclePolicies',
|
|
268
|
+
'promptSlots',
|
|
269
|
+
'fallbackResponses',
|
|
270
|
+
'widgetDependencies',
|
|
271
|
+
'contentRating',
|
|
272
|
+
'portalMetadata',
|
|
273
|
+
] as const;
|
|
274
|
+
|
|
275
|
+
for (const field of REQUIRED_FIELDS) {
|
|
276
|
+
it(`rejects a manifest missing '${field}' with a useful located error`, () => {
|
|
277
|
+
const fixture: Record<string, unknown> = cloneFixture();
|
|
278
|
+
delete fixture[field];
|
|
279
|
+
const result = ManifestSchema.safeParse(fixture);
|
|
280
|
+
expect(result.success).toBe(false);
|
|
281
|
+
if (!result.success) {
|
|
282
|
+
expect(
|
|
283
|
+
result.error.issues.some((issue) => issue.path[0] === field),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('ManifestSchema — bundle reservations (Chunk B18)', () => {
|
|
291
|
+
it('accepts the baseline fixture without bundle fields', () => {
|
|
292
|
+
const result = ManifestSchema.safeParse(validManifestFixture);
|
|
293
|
+
expect(result.success).toBe(true);
|
|
294
|
+
if (result.success) {
|
|
295
|
+
expect(result.data.bundleCompatible).toBeUndefined();
|
|
296
|
+
expect(result.data.bundleScopedDimensions).toBeUndefined();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('accepts bundle fields and applies defaults on parse', () => {
|
|
301
|
+
const fixture = {
|
|
302
|
+
...cloneFixture(),
|
|
303
|
+
bundleCompatible: true,
|
|
304
|
+
bundleScopedDimensions: ['score'],
|
|
305
|
+
bundleWriteDimensions: ['notes'],
|
|
306
|
+
bundleContextSlots: { theme: 'party' },
|
|
307
|
+
};
|
|
308
|
+
const result = ManifestSchema.safeParse(fixture);
|
|
309
|
+
expect(result.success).toBe(true);
|
|
310
|
+
if (result.success) {
|
|
311
|
+
expect(result.data.bundleCompatible).toBe(true);
|
|
312
|
+
expect(result.data.bundleScopedDimensions).toEqual(['score']);
|
|
313
|
+
expect(result.data.bundleWriteDimensions).toEqual(['notes']);
|
|
314
|
+
expect(result.data.bundleContextSlots).toEqual({ theme: 'party' });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('rejects a non-array bundleScopedDimensions', () => {
|
|
319
|
+
const fixture: Record<string, unknown> = cloneFixture();
|
|
320
|
+
fixture.bundleScopedDimensions = 'not-an-array';
|
|
321
|
+
expect(ManifestSchema.safeParse(fixture).success).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('ManifestSchema — optional fields', () => {
|
|
326
|
+
it('accepts a manifest with `tenant` set to null (first-party Experience)', () => {
|
|
327
|
+
const fixture = cloneFixture();
|
|
328
|
+
fixture.tenant = null;
|
|
329
|
+
expect(ManifestSchema.safeParse(fixture).success).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('accepts a manifest with `tenant` omitted', () => {
|
|
333
|
+
const fixture: Record<string, unknown> = cloneFixture();
|
|
334
|
+
delete fixture.tenant;
|
|
335
|
+
expect(ManifestSchema.safeParse(fixture).success).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('rejects a malformed `tenant` id', () => {
|
|
339
|
+
const fixture = cloneFixture() as { tenant?: unknown };
|
|
340
|
+
fixture.tenant = 'not-a-tenant-id';
|
|
341
|
+
expect(ManifestSchema.safeParse(fixture).success).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('ManifestSchema — createdAt format', () => {
|
|
346
|
+
it('rejects a non-ISO-8601 createdAt', () => {
|
|
347
|
+
const fixture = cloneFixture();
|
|
348
|
+
fixture.createdAt = 'last Tuesday';
|
|
349
|
+
expect(ManifestSchema.safeParse(fixture).success).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
});
|