@akinon/pz-theme 2.0.162-beta.0 → 2.0.162

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,500 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import {
5
+ emptyRegionRegistry,
6
+ isRegionRegistrableSlug,
7
+ isRegionReservedSlug,
8
+ isValidRegionCode,
9
+ isWritableRegionRegistry,
10
+ REGION_CONTENT_CAPABILITY,
11
+ REGION_DATA_SOURCES_ATTRIBUTE,
12
+ REGION_REGISTRY_ATTRIBUTE,
13
+ REGION_REGISTRY_SLUG,
14
+ REGION_REGISTRY_VERSION,
15
+ REGION_SLUG_INFIX,
16
+ regionFromLocale,
17
+ regionHasPlaceholder,
18
+ marketPlaceholderSlugs,
19
+ regionSlug,
20
+ readRegionDataSources,
21
+ readRegionRegistry,
22
+ serializeRegionRegistry
23
+ } from '../region-contract';
24
+ import type { RegionRegistry, RegionRegistryRead } from '../region-contract';
25
+
26
+ /**
27
+ * ZERO-5391 — the contract both sides of per-country ("region") content read
28
+ * from: which country a locale belongs to, where a country's copy of an area
29
+ * lives, and how the `theme-regions` registry and a copy's own data-source
30
+ * records are parsed. The storefront and the editor must agree on every one of
31
+ * these, so the file is kept as two byte-identical copies and the last test
32
+ * compares them.
33
+ */
34
+
35
+ const registryOf = (read: RegionRegistryRead): RegionRegistry => {
36
+ if (read.kind !== 'found') {
37
+ throw new Error(`expected a found registry, got ${JSON.stringify(read)}`);
38
+ }
39
+ return read.registry;
40
+ };
41
+
42
+ const SAMPLE = {
43
+ version: 1,
44
+ regions: {
45
+ ae: {
46
+ placeholders: {
47
+ header: { updatedAt: '2026-11-02T10:00:00.000Z' },
48
+ 'theme-homepage-body': {}
49
+ }
50
+ }
51
+ }
52
+ };
53
+
54
+ describe('region contract — wire names', () => {
55
+ it('pins every name the storefront and the editor exchange', () => {
56
+ expect(REGION_SLUG_INFIX).toBe('-region-');
57
+ expect(REGION_REGISTRY_SLUG).toBe('theme-regions');
58
+ expect(REGION_REGISTRY_ATTRIBUTE).toBe('theme_regions');
59
+ expect(REGION_REGISTRY_VERSION).toBe(1);
60
+ expect(REGION_CONTENT_CAPABILITY).toBe('region-content');
61
+ expect(REGION_DATA_SOURCES_ATTRIBUTE).toBe('theme_region_dataSources');
62
+ });
63
+ });
64
+
65
+ describe('regionFromLocale', () => {
66
+ it.each([
67
+ ['en-ae', 'ae'],
68
+ ['ar-ae', 'ae'],
69
+ ['AR-AE', 'ae'],
70
+ [' en-ae ', 'ae'],
71
+ ['en-us', 'us'],
72
+ ['zh-hant-tw', 'tw']
73
+ ])('reads the country of %p as %p', (locale: string, region: string) => {
74
+ expect(regionFromLocale(locale)).toBe(region);
75
+ });
76
+
77
+ it.each([['en'], ['tr'], ['zh-hant'], ['es-419'], ['en-'], ['']])(
78
+ 'gives no region for %p',
79
+ (locale: string) => {
80
+ expect(regionFromLocale(locale)).toBe('');
81
+ }
82
+ );
83
+
84
+ it.each([[undefined], [null], [42], [{ value: 'en-ae' }]])(
85
+ 'gives no region for the non-string %p',
86
+ (locale: unknown) => {
87
+ expect(regionFromLocale(locale)).toBe('');
88
+ }
89
+ );
90
+ });
91
+
92
+ describe('isValidRegionCode', () => {
93
+ it('accepts two lowercase letters', () => {
94
+ expect(isValidRegionCode('ae')).toBe(true);
95
+ expect(isValidRegionCode('tr')).toBe(true);
96
+ });
97
+
98
+ it.each([
99
+ ['AE'],
100
+ ['uae'],
101
+ ['a1'],
102
+ ['__proto__'],
103
+ ['a'],
104
+ [''],
105
+ [undefined],
106
+ [7]
107
+ ])('rejects %p', (code: unknown) => {
108
+ expect(isValidRegionCode(code)).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('regionSlug', () => {
113
+ it('suffixes the canonical slug with the region', () => {
114
+ expect(regionSlug('header', 'ae')).toBe('header-region-ae');
115
+ expect(regionSlug('page-faq', 'sa')).toBe('page-faq-region-sa');
116
+ });
117
+
118
+ it('composes with a preview suffix outermost', () => {
119
+ expect(`${regionSlug('header', 'ae')}-preview-draft`).toBe(
120
+ 'header-region-ae-preview-draft'
121
+ );
122
+ });
123
+ });
124
+
125
+ describe('isRegionReservedSlug', () => {
126
+ it('reserves every slug carrying the infix', () => {
127
+ expect(isRegionReservedSlug(regionSlug('header', 'ae'))).toBe(true);
128
+ expect(isRegionReservedSlug('page-super-region-deals')).toBe(true);
129
+ });
130
+
131
+ it('leaves slugs without the full infix alone', () => {
132
+ expect(isRegionReservedSlug('header')).toBe(false);
133
+ expect(isRegionReservedSlug('superregion')).toBe(false);
134
+ expect(isRegionReservedSlug('header-regions')).toBe(false);
135
+ });
136
+
137
+ it('sees a custom page through its placeholder slug', () => {
138
+ // The page slug passes; the placeholder `page-<slug>` does not, which is
139
+ // why custom page slugs are validated as `page-${slug}`.
140
+ expect(isRegionReservedSlug('region-days')).toBe(false);
141
+ expect(isRegionReservedSlug('page-region-days')).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe('isRegionRegistrableSlug', () => {
146
+ const REGISTRABLE = [
147
+ 'header',
148
+ 'footer',
149
+ 'theme-homepage-body',
150
+ 'page-faq',
151
+ 'modal-1a2b3c',
152
+ 'server-error-body',
153
+ '0-leading-digit'
154
+ ];
155
+ const UNREGISTRABLE = [
156
+ 'page-region-days',
157
+ 'header-region-ae',
158
+ 'promo_bar',
159
+ 'Header',
160
+ '-header',
161
+ 'page faq',
162
+ '',
163
+ '__proto__'
164
+ ];
165
+
166
+ it.each(REGISTRABLE.map((slug: string) => [slug]))(
167
+ 'accepts %p',
168
+ (slug: string) => {
169
+ expect(isRegionRegistrableSlug(slug)).toBe(true);
170
+ }
171
+ );
172
+
173
+ it.each(UNREGISTRABLE.map((slug: string) => [slug]))(
174
+ 'refuses %p',
175
+ (slug: string) => {
176
+ expect(isRegionRegistrableSlug(slug)).toBe(false);
177
+ }
178
+ );
179
+
180
+ it.each([[undefined], [null], [42], [['header']]])(
181
+ 'refuses the non-string %p',
182
+ (slug: unknown) => {
183
+ expect(isRegionRegistrableSlug(slug)).toBe(false);
184
+ }
185
+ );
186
+
187
+ it('accepts exactly the slugs a registry read keeps', () => {
188
+ const all = [...REGISTRABLE, ...UNREGISTRABLE];
189
+ // Own keys, `__proto__` included (an assignment would set the prototype
190
+ // instead), serialised the way the widget stores them.
191
+ const placeholders = Object.fromEntries(
192
+ all.map((slug: string) => [slug, {}])
193
+ );
194
+ const registry = registryOf(
195
+ readRegionRegistry(
196
+ JSON.stringify({ version: 1, regions: { ae: { placeholders } } })
197
+ )
198
+ );
199
+
200
+ for (const slug of all) {
201
+ expect([slug, regionHasPlaceholder(registry, 'ae', slug)]).toEqual([
202
+ slug,
203
+ isRegionRegistrableSlug(slug)
204
+ ]);
205
+ }
206
+ });
207
+ });
208
+
209
+ describe('readRegionRegistry', () => {
210
+ it.each([[undefined], [null], [''], [{ value: '' }], [{ value: null }]])(
211
+ 'reads %p as absent',
212
+ (raw: unknown) => {
213
+ expect(readRegionRegistry(raw)).toEqual({ kind: 'absent' });
214
+ }
215
+ );
216
+
217
+ it('reads a JSON string', () => {
218
+ expect(readRegionRegistry(JSON.stringify(SAMPLE))).toEqual({
219
+ kind: 'found',
220
+ registry: SAMPLE
221
+ });
222
+ });
223
+
224
+ it('reads the commerce {value, kwargs} wrapping', () => {
225
+ expect(
226
+ readRegionRegistry({ value: JSON.stringify(SAMPLE), kwargs: {} })
227
+ ).toEqual({ kind: 'found', registry: SAMPLE });
228
+ expect(readRegionRegistry({ value: SAMPLE, kwargs: {} })).toEqual({
229
+ kind: 'found',
230
+ registry: SAMPLE
231
+ });
232
+ });
233
+
234
+ it('reads an already parsed object without keeping a reference to it', () => {
235
+ const read = readRegionRegistry(SAMPLE);
236
+
237
+ expect(read).toEqual({ kind: 'found', registry: SAMPLE });
238
+ expect(registryOf(read)).not.toBe(SAMPLE);
239
+ expect(registryOf(read).regions.ae).not.toBe(SAMPLE.regions.ae);
240
+ });
241
+
242
+ it('reads malformed JSON as unreadable', () => {
243
+ expect(readRegionRegistry('{nope')).toEqual({
244
+ kind: 'unreadable',
245
+ reason: 'malformed-json'
246
+ });
247
+ });
248
+
249
+ it.each([['[]'], ['"text"'], [42], [true]])(
250
+ 'reads the non-object %p as unreadable',
251
+ (raw: unknown) => {
252
+ expect(readRegionRegistry(raw)).toEqual({
253
+ kind: 'unreadable',
254
+ reason: 'not-an-object'
255
+ });
256
+ }
257
+ );
258
+
259
+ it.each([
260
+ [{ regions: {} }],
261
+ [{ version: 0, regions: {} }],
262
+ [{ version: '1', regions: {} }],
263
+ [{ version: NaN, regions: {} }]
264
+ ])('reads %p as a bad version', (raw: unknown) => {
265
+ expect(readRegionRegistry(raw)).toEqual({
266
+ kind: 'unreadable',
267
+ reason: 'bad-version'
268
+ });
269
+ });
270
+
271
+ it.each([
272
+ [{ version: 1 }],
273
+ [{ version: 1, regions: null }],
274
+ [{ version: 1, regions: [] }],
275
+ [{ version: 1, regions: 'ae' }]
276
+ ])('reads %p as having no regions', (raw: unknown) => {
277
+ expect(readRegionRegistry(raw)).toEqual({
278
+ kind: 'unreadable',
279
+ reason: 'no-regions'
280
+ });
281
+ });
282
+
283
+ it('drops invalid codes and slugs while keeping their siblings', () => {
284
+ const raw = JSON.stringify({
285
+ version: 1,
286
+ regions: {
287
+ ae: {
288
+ placeholders: {
289
+ header: {},
290
+ Header: {},
291
+ 'header-region-ae': {},
292
+ ['__proto__']: {},
293
+ footer: {}
294
+ }
295
+ },
296
+ AE: { placeholders: { header: {} } },
297
+ uae: { placeholders: { header: {} } },
298
+ ['__proto__']: { placeholders: { header: {} } },
299
+ sa: { placeholders: { 'page-faq': {} } },
300
+ tr: 'not-an-entry',
301
+ us: { placeholders: ['header'] },
302
+ de: {}
303
+ }
304
+ });
305
+ const registry = registryOf(readRegionRegistry(raw));
306
+
307
+ expect(registry).toEqual({
308
+ version: 1,
309
+ regions: {
310
+ ae: { placeholders: { header: {}, footer: {} } },
311
+ sa: { placeholders: { 'page-faq': {} } }
312
+ }
313
+ });
314
+ expect(Object.getPrototypeOf(registry.regions)).toBe(Object.prototype);
315
+ expect(Object.getPrototypeOf(registry.regions.ae.placeholders)).toBe(
316
+ Object.prototype
317
+ );
318
+ });
319
+
320
+ it('keeps updatedAt only when it is a string, and drops other entry keys', () => {
321
+ const registry = registryOf(
322
+ readRegionRegistry({
323
+ version: 1,
324
+ regions: {
325
+ ae: {
326
+ placeholders: {
327
+ header: { updatedAt: '2026-11-02T10:00:00.000Z', live: true },
328
+ footer: { updatedAt: 1762077600000 },
329
+ 'page-faq': { updatedAt: null },
330
+ 'modal-1a2b3c': 'not-an-object'
331
+ }
332
+ }
333
+ }
334
+ })
335
+ );
336
+
337
+ expect(registry.regions.ae.placeholders).toEqual({
338
+ header: { updatedAt: '2026-11-02T10:00:00.000Z' },
339
+ footer: {},
340
+ 'page-faq': {},
341
+ 'modal-1a2b3c': {}
342
+ });
343
+ });
344
+
345
+ it('reads a later version that only added keys', () => {
346
+ const read = readRegionRegistry(
347
+ JSON.stringify({
348
+ version: 2,
349
+ drafts: { ae: { placeholders: { footer: {} } } },
350
+ regions: {
351
+ ae: {
352
+ placeholders: { header: { updatedAt: 'x', live: false } },
353
+ locales: ['en-ae']
354
+ }
355
+ }
356
+ })
357
+ );
358
+
359
+ expect(read).toEqual({
360
+ kind: 'found',
361
+ registry: {
362
+ version: 2,
363
+ regions: { ae: { placeholders: { header: { updatedAt: 'x' } } } }
364
+ }
365
+ });
366
+ });
367
+ });
368
+
369
+ describe('regionHasPlaceholder and marketPlaceholderSlugs', () => {
370
+ const registry = registryOf(readRegionRegistry(SAMPLE));
371
+
372
+ it('list the placeholders a region has its own copy of', () => {
373
+ expect(marketPlaceholderSlugs(registry, 'ae')).toEqual([
374
+ 'header',
375
+ 'theme-homepage-body'
376
+ ]);
377
+ expect(regionHasPlaceholder(registry, 'ae', 'header')).toBe(true);
378
+ expect(regionHasPlaceholder(registry, 'ae', 'footer')).toBe(false);
379
+ expect(marketPlaceholderSlugs(registry, 'sa')).toEqual([]);
380
+ expect(regionHasPlaceholder(registry, 'sa', 'header')).toBe(false);
381
+ });
382
+
383
+ it('use own properties only', () => {
384
+ for (const key of ['constructor', 'toString', '__proto__']) {
385
+ expect(marketPlaceholderSlugs(registry, key)).toEqual([]);
386
+ expect(regionHasPlaceholder(registry, key, 'header')).toBe(false);
387
+ expect(regionHasPlaceholder(registry, 'ae', key)).toBe(false);
388
+ }
389
+ });
390
+ });
391
+
392
+ describe('isWritableRegionRegistry and serializeRegionRegistry', () => {
393
+ it('writes only registries of this version or older', () => {
394
+ expect(isWritableRegionRegistry(emptyRegionRegistry())).toBe(true);
395
+ expect(isWritableRegionRegistry({ version: 1, regions: {} })).toBe(true);
396
+ expect(isWritableRegionRegistry({ version: 2, regions: {} })).toBe(false);
397
+ });
398
+
399
+ it('stamps the current version, which is why a later one must not be written', () => {
400
+ expect(
401
+ JSON.parse(serializeRegionRegistry({ version: 2, regions: {} })).version
402
+ ).toBe(REGION_REGISTRY_VERSION);
403
+ });
404
+
405
+ it('serialises the empty registry', () => {
406
+ expect(serializeRegionRegistry(emptyRegionRegistry())).toBe(
407
+ '{"version":1,"regions":{}}'
408
+ );
409
+ });
410
+
411
+ it('round-trips through readRegionRegistry', () => {
412
+ const registry = registryOf(readRegionRegistry(SAMPLE));
413
+ const serialized = serializeRegionRegistry(registry);
414
+
415
+ expect(readRegionRegistry(serialized)).toEqual({ kind: 'found', registry });
416
+ expect(readRegionRegistry({ value: serialized, kwargs: {} })).toEqual({
417
+ kind: 'found',
418
+ registry
419
+ });
420
+ });
421
+ });
422
+
423
+ describe('readRegionDataSources', () => {
424
+ const RECORDS = [
425
+ { id: 'ds-9', name: 'Bestsellers (AE)', region: 'ae' },
426
+ { id: 'ds-10', name: 'New in (AE)', region: 'ae' }
427
+ ];
428
+
429
+ it('reads a JSON string', () => {
430
+ expect(readRegionDataSources(JSON.stringify(RECORDS))).toEqual(RECORDS);
431
+ });
432
+
433
+ it('reads the commerce {value, kwargs} wrapping', () => {
434
+ expect(
435
+ readRegionDataSources({ value: JSON.stringify(RECORDS), kwargs: {} })
436
+ ).toEqual(RECORDS);
437
+ expect(readRegionDataSources({ value: RECORDS, kwargs: {} })).toEqual(
438
+ RECORDS
439
+ );
440
+ });
441
+
442
+ it('reads an already parsed array into a new array', () => {
443
+ const read = readRegionDataSources(RECORDS);
444
+
445
+ expect(read).toEqual(RECORDS);
446
+ expect(read).not.toBe(RECORDS);
447
+ });
448
+
449
+ it('returns the elements unvalidated', () => {
450
+ expect(readRegionDataSources('[1,null,"x",{"id":"ds-1"}]')).toEqual([
451
+ 1,
452
+ null,
453
+ 'x',
454
+ { id: 'ds-1' }
455
+ ]);
456
+ expect(readRegionDataSources('[]')).toEqual([]);
457
+ });
458
+
459
+ it.each([
460
+ [undefined],
461
+ [null],
462
+ [''],
463
+ ['{nope'],
464
+ ['{}'],
465
+ ['"text"'],
466
+ ['null'],
467
+ [42],
468
+ [true],
469
+ [{}],
470
+ [{ id: 'ds-1' }],
471
+ [{ value: null }],
472
+ [{ value: '{nope' }],
473
+ [{ value: { id: 'ds-1' } }]
474
+ ])('reads %p as no records', (raw: unknown) => {
475
+ expect(readRegionDataSources(raw)).toEqual([]);
476
+ });
477
+ });
478
+
479
+ describe('region contract — the editor copy', () => {
480
+ const SOURCE = path.resolve(__dirname, '../region-contract.ts');
481
+ // Sibling checkouts: this machine's name for the editor first, then the
482
+ // name a default clone of its repository gets.
483
+ const EDITOR = [
484
+ '../../../../../../pz-dynamic-widget/src/services/region/region-contract.ts',
485
+ '../../../../../../pz-theme-editor/src/services/region/region-contract.ts'
486
+ ]
487
+ .map((relative: string) => path.resolve(__dirname, relative))
488
+ .find((candidate: string) => fs.existsSync(candidate));
489
+
490
+ // Skipped, not failed, where the editor is not checked out beside pz-next
491
+ // (CI builds this repo alone). The reads stay inside it() so a missing file
492
+ // can never fail the collection of the tests above.
493
+ (EDITOR ? it : it.skip)('is byte-identical to this copy', () => {
494
+ const storefront = fs.readFileSync(SOURCE);
495
+ const editor = fs.readFileSync(EDITOR as string);
496
+
497
+ expect(editor.toString('utf8')).toBe(storefront.toString('utf8'));
498
+ expect(editor.equals(storefront)).toBe(true);
499
+ });
500
+ });