@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.
- package/CHANGELOG.md +4 -4
- package/package.json +4 -4
- package/readme.md +22 -0
- package/src/__tests__/custom-catalog-announce.test.ts +110 -0
- package/src/chrome/composition-source.ts +7 -2
- package/src/chrome/preview-snapshot-source.ts +5 -1
- package/src/content-capabilities.ts +41 -0
- package/src/load-placeholder-sections.ts +13 -3
- package/src/placeholder-registry.ts +42 -1
- package/src/region/__tests__/editor-handshake.test.ts +144 -0
- package/src/region/__tests__/region-contract.test.ts +500 -0
- package/src/region/__tests__/region-placeholder-readers.test.ts +431 -0
- package/src/region/__tests__/region-source.test.ts +621 -0
- package/src/region/editor-handshake.ts +60 -0
- package/src/region/region-contract.ts +184 -0
- package/src/region/region-source.ts +360 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5391 — the storefront's region source: which region a request belongs
|
|
3
|
+
* to, how the `theme-regions` registry is read and remembered, which slug a
|
|
4
|
+
* placeholder resolves to, when a region copy counts, and how a copy's own
|
|
5
|
+
* data-source records join theme-config's.
|
|
6
|
+
*/
|
|
7
|
+
jest.mock('react', () => {
|
|
8
|
+
const actual = jest.requireActual('react');
|
|
9
|
+
// Jest runs the client build, where `cache` does not memoize. Give the module
|
|
10
|
+
// the RSC behaviour (same args, same settled promise) with one store per
|
|
11
|
+
// wrapped function; clearing the stores starts a new request. The stores
|
|
12
|
+
// live in the factory: a top-level const would be in its TDZ when the
|
|
13
|
+
// hoisted factory runs.
|
|
14
|
+
const stores: Map<string, unknown>[] = [];
|
|
15
|
+
const cache = <A extends unknown[], R>(fn: (...args: A) => R) => {
|
|
16
|
+
const store = new Map<string, R>();
|
|
17
|
+
stores.push(store as Map<string, unknown>);
|
|
18
|
+
return (...args: A): R => {
|
|
19
|
+
const key = JSON.stringify(args);
|
|
20
|
+
if (!store.has(key)) store.set(key, fn(...args));
|
|
21
|
+
return store.get(key) as R;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
cache,
|
|
27
|
+
__clearCaches: () => stores.forEach((store) => store.clear())
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const getWidgetData = jest.fn();
|
|
32
|
+
const appFetch = jest.fn();
|
|
33
|
+
const draftMode = jest.fn();
|
|
34
|
+
|
|
35
|
+
jest.mock('@akinon/next/data/server', () => ({
|
|
36
|
+
getWidgetData: (...args: unknown[]) => getWidgetData(...args)
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
jest.mock(
|
|
40
|
+
'@akinon/next/utils/app-fetch',
|
|
41
|
+
() => ({
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: (...args: unknown[]) => appFetch(...args)
|
|
44
|
+
}),
|
|
45
|
+
{ virtual: true }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
jest.mock(
|
|
49
|
+
'@akinon/next/utils/server-variables',
|
|
50
|
+
() => ({
|
|
51
|
+
ServerVariables: { locale: 'en-ae', currency: 'usd', previewId: '' }
|
|
52
|
+
}),
|
|
53
|
+
{ virtual: true }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
jest.mock('next/headers', () => ({
|
|
57
|
+
draftMode: (...args: unknown[]) => draftMode(...args)
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
__resetRegionSourceForTests,
|
|
62
|
+
getRegionRegistry,
|
|
63
|
+
getRequestRegion,
|
|
64
|
+
REGION_REGISTRY_FALLBACK_MEMO_MS,
|
|
65
|
+
REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS,
|
|
66
|
+
REGION_REGISTRY_MEMO_MS,
|
|
67
|
+
readMarketPlaceholderCopy,
|
|
68
|
+
resolveRegionSlug,
|
|
69
|
+
withRegionDataSources
|
|
70
|
+
} from '../region-source';
|
|
71
|
+
|
|
72
|
+
const { ServerVariables } = jest.requireMock(
|
|
73
|
+
'@akinon/next/utils/server-variables'
|
|
74
|
+
) as {
|
|
75
|
+
ServerVariables: { locale: string; currency: string; previewId?: unknown };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A new request: every React.cache store is emptied. */
|
|
79
|
+
const newRequest = (): void =>
|
|
80
|
+
(jest.requireMock('react') as { __clearCaches: () => void }).__clearCaches();
|
|
81
|
+
|
|
82
|
+
const T0 = 1_700_000_000_000;
|
|
83
|
+
let now = T0;
|
|
84
|
+
let nowSpy: jest.SpyInstance<number, []>;
|
|
85
|
+
let warnSpy: jest.SpyInstance;
|
|
86
|
+
|
|
87
|
+
/** The registry widget as commerce answers it: the attribute wrapped. */
|
|
88
|
+
const registryWidget = (regions: Record<string, string[]>) => ({
|
|
89
|
+
slug: 'theme-regions',
|
|
90
|
+
attributes: {
|
|
91
|
+
theme_regions: {
|
|
92
|
+
value: JSON.stringify({
|
|
93
|
+
version: 1,
|
|
94
|
+
regions: Object.fromEntries(
|
|
95
|
+
Object.entries(regions).map(([code, slugs]) => [
|
|
96
|
+
code,
|
|
97
|
+
{
|
|
98
|
+
placeholders: Object.fromEntries(
|
|
99
|
+
slugs.map((slug) => [slug, { updatedAt: '2026-11-02' }])
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
])
|
|
103
|
+
)
|
|
104
|
+
}),
|
|
105
|
+
kwargs: {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const AE_HEADER = registryWidget({ ae: ['header'] });
|
|
111
|
+
|
|
112
|
+
const slugOf = (call: unknown[]): string => (call[0] as { slug: string }).slug;
|
|
113
|
+
|
|
114
|
+
const registryReads = (): number =>
|
|
115
|
+
getWidgetData.mock.calls.filter((call) => slugOf(call) === 'theme-regions')
|
|
116
|
+
.length;
|
|
117
|
+
|
|
118
|
+
const readsOf = (slug: string): number =>
|
|
119
|
+
getWidgetData.mock.calls.filter((call) => slugOf(call) === slug).length;
|
|
120
|
+
|
|
121
|
+
/** Answers the registry slug with `registry`, every other slug with `others`. */
|
|
122
|
+
const serve = (
|
|
123
|
+
registry: (() => unknown) | unknown,
|
|
124
|
+
others: (slug: string) => unknown = () => null
|
|
125
|
+
) =>
|
|
126
|
+
getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
|
|
127
|
+
if (slug !== 'theme-regions') return others(slug);
|
|
128
|
+
return typeof registry === 'function' ? registry() : registry;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
getWidgetData.mockReset();
|
|
133
|
+
appFetch.mockReset();
|
|
134
|
+
draftMode.mockReset().mockResolvedValue({ isEnabled: false });
|
|
135
|
+
ServerVariables.locale = 'en-ae';
|
|
136
|
+
ServerVariables.currency = 'usd';
|
|
137
|
+
ServerVariables.previewId = '';
|
|
138
|
+
now = T0;
|
|
139
|
+
nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now);
|
|
140
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
141
|
+
__resetRegionSourceForTests();
|
|
142
|
+
newRequest();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
nowSpy.mockRestore();
|
|
147
|
+
warnSpy.mockRestore();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('getRequestRegion', () => {
|
|
151
|
+
it('derives the region from the request locale', () => {
|
|
152
|
+
expect(getRequestRegion()).toBe('ae');
|
|
153
|
+
|
|
154
|
+
for (const [locale, region] of [
|
|
155
|
+
['tr', ''],
|
|
156
|
+
['EN-AE', 'ae'],
|
|
157
|
+
['ar-ae', 'ae'],
|
|
158
|
+
['zh-hant-tw', 'tw'],
|
|
159
|
+
['es-419', '']
|
|
160
|
+
]) {
|
|
161
|
+
newRequest();
|
|
162
|
+
ServerVariables.locale = locale;
|
|
163
|
+
expect(getRequestRegion()).toBe(region);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('pins one region per request', () => {
|
|
168
|
+
expect(getRequestRegion()).toBe('ae');
|
|
169
|
+
ServerVariables.locale = 'tr';
|
|
170
|
+
expect(getRequestRegion()).toBe('ae');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('a language-only locale', () => {
|
|
175
|
+
it('resolves every slug to itself without any I/O', async () => {
|
|
176
|
+
ServerVariables.locale = 'tr';
|
|
177
|
+
|
|
178
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
179
|
+
expect(await readMarketPlaceholderCopy('header')).toBeNull();
|
|
180
|
+
|
|
181
|
+
expect(getWidgetData).not.toHaveBeenCalled();
|
|
182
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
183
|
+
expect(draftMode).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('the live registry read', () => {
|
|
188
|
+
it('reads the registry once per request, through the cached core reader', async () => {
|
|
189
|
+
serve(AE_HEADER);
|
|
190
|
+
|
|
191
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
192
|
+
expect(await resolveRegionSlug('footer')).toBe('footer');
|
|
193
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
194
|
+
|
|
195
|
+
expect(getWidgetData).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(getWidgetData).toHaveBeenCalledWith({
|
|
197
|
+
slug: 'theme-regions',
|
|
198
|
+
locale: 'en-ae',
|
|
199
|
+
cacheOptions: { expire: 60 }
|
|
200
|
+
});
|
|
201
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('remembers a found registry for 60 s across requests', async () => {
|
|
205
|
+
serve(AE_HEADER);
|
|
206
|
+
await getRegionRegistry();
|
|
207
|
+
|
|
208
|
+
newRequest();
|
|
209
|
+
now = T0 + REGION_REGISTRY_MEMO_MS - 1;
|
|
210
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
211
|
+
expect(registryReads()).toBe(1);
|
|
212
|
+
|
|
213
|
+
newRequest();
|
|
214
|
+
now = T0 + REGION_REGISTRY_MEMO_MS + 1;
|
|
215
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
216
|
+
expect(registryReads()).toBe(2);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('remembers an absent registry the same way, after a single attempt', async () => {
|
|
220
|
+
serve(undefined);
|
|
221
|
+
|
|
222
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
223
|
+
expect(registryReads()).toBe(1);
|
|
224
|
+
|
|
225
|
+
newRequest();
|
|
226
|
+
now = T0 + REGION_REGISTRY_MEMO_MS - 1;
|
|
227
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
228
|
+
expect(registryReads()).toBe(1);
|
|
229
|
+
|
|
230
|
+
newRequest();
|
|
231
|
+
now = T0 + REGION_REGISTRY_MEMO_MS + 1;
|
|
232
|
+
await resolveRegionSlug('header');
|
|
233
|
+
expect(registryReads()).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('keeps one memo per locale', async () => {
|
|
237
|
+
serve(AE_HEADER);
|
|
238
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
239
|
+
|
|
240
|
+
newRequest();
|
|
241
|
+
ServerVariables.locale = 'ar-ae';
|
|
242
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
243
|
+
|
|
244
|
+
expect(getWidgetData.mock.calls.map(([args]) => args.locale)).toEqual([
|
|
245
|
+
'en-ae',
|
|
246
|
+
'ar-ae'
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('turns an unreadable registry into the default content and warns once per memo window', async () => {
|
|
251
|
+
serve({ attributes: { theme_regions: { value: '{nope', kwargs: {} } } });
|
|
252
|
+
|
|
253
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
254
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(warnSpy.mock.calls[0][0]).toContain('"theme-regions" is unreadable');
|
|
256
|
+
|
|
257
|
+
newRequest();
|
|
258
|
+
now = T0 + 1_000;
|
|
259
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
260
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('turns a failing read into the default content', async () => {
|
|
264
|
+
getWidgetData.mockRejectedValue(new Error('commerce is down'));
|
|
265
|
+
|
|
266
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
267
|
+
expect(registryReads()).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('a named preview (draft mode and a preview id)', () => {
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
ServerVariables.previewId = 'draft';
|
|
274
|
+
draftMode.mockResolvedValue({ isEnabled: true });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('reads the registry uncached and never memoises it', async () => {
|
|
278
|
+
appFetch.mockResolvedValue(AE_HEADER);
|
|
279
|
+
|
|
280
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
281
|
+
expect(appFetch).toHaveBeenCalledTimes(1);
|
|
282
|
+
expect(appFetch).toHaveBeenCalledWith({
|
|
283
|
+
url: '/widgets/theme-regions/',
|
|
284
|
+
locale: 'en-ae',
|
|
285
|
+
currency: 'usd',
|
|
286
|
+
init: { next: { revalidate: 0 } }
|
|
287
|
+
});
|
|
288
|
+
expect(getWidgetData).not.toHaveBeenCalled();
|
|
289
|
+
|
|
290
|
+
newRequest();
|
|
291
|
+
await resolveRegionSlug('header');
|
|
292
|
+
expect(appFetch).toHaveBeenCalledTimes(2);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('leaves the live memo alone', async () => {
|
|
296
|
+
appFetch.mockResolvedValue(AE_HEADER);
|
|
297
|
+
await resolveRegionSlug('header');
|
|
298
|
+
|
|
299
|
+
newRequest();
|
|
300
|
+
ServerVariables.previewId = '';
|
|
301
|
+
serve(registryWidget({ ae: ['footer'] }));
|
|
302
|
+
expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
|
|
303
|
+
expect(registryReads()).toBe(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('needs draft mode: a preview id alone reads the live registry', async () => {
|
|
307
|
+
draftMode.mockResolvedValue({ isEnabled: false });
|
|
308
|
+
serve(AE_HEADER);
|
|
309
|
+
|
|
310
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
311
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
312
|
+
expect(registryReads()).toBe(1);
|
|
313
|
+
|
|
314
|
+
// ...and is memoised like any live read.
|
|
315
|
+
newRequest();
|
|
316
|
+
await resolveRegionSlug('header');
|
|
317
|
+
expect(registryReads()).toBe(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('needs a preview id: draft mode alone reads the live registry', async () => {
|
|
321
|
+
ServerVariables.previewId = '';
|
|
322
|
+
serve(AE_HEADER);
|
|
323
|
+
|
|
324
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
325
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
326
|
+
expect(registryReads()).toBe(1);
|
|
327
|
+
// Ordinary traffic never has to ask for draft mode.
|
|
328
|
+
expect(draftMode).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('reads live when draft mode cannot be asked (no request scope)', async () => {
|
|
332
|
+
draftMode.mockImplementation(() => {
|
|
333
|
+
throw new Error('`draftMode` was called outside a request scope.');
|
|
334
|
+
});
|
|
335
|
+
serve(AE_HEADER);
|
|
336
|
+
|
|
337
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
338
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
339
|
+
expect(registryReads()).toBe(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('reads live on a core without preview ids', async () => {
|
|
343
|
+
ServerVariables.previewId = undefined;
|
|
344
|
+
serve(AE_HEADER);
|
|
345
|
+
|
|
346
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
347
|
+
expect(appFetch).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('a registry the locale has found survives a failing read', () => {
|
|
352
|
+
/** A found read at T0, then the memo window lapses. */
|
|
353
|
+
const foundThenLapse = async (): Promise<void> => {
|
|
354
|
+
serve(AE_HEADER);
|
|
355
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
356
|
+
getWidgetData.mockClear();
|
|
357
|
+
newRequest();
|
|
358
|
+
now = T0 + REGION_REGISTRY_MEMO_MS + 1;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
it('keeps serving the last found registry through a transient miss, after one extra attempt', async () => {
|
|
362
|
+
await foundThenLapse();
|
|
363
|
+
serve(undefined);
|
|
364
|
+
|
|
365
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
366
|
+
expect(registryReads()).toBe(2);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('memoises that fallback only briefly, so the next request checks again', async () => {
|
|
370
|
+
await foundThenLapse();
|
|
371
|
+
serve(undefined);
|
|
372
|
+
await resolveRegionSlug('header');
|
|
373
|
+
const lapsedAt = now;
|
|
374
|
+
|
|
375
|
+
newRequest();
|
|
376
|
+
now = lapsedAt + REGION_REGISTRY_FALLBACK_MEMO_MS - 1;
|
|
377
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
378
|
+
expect(registryReads()).toBe(2);
|
|
379
|
+
|
|
380
|
+
newRequest();
|
|
381
|
+
now = lapsedAt + REGION_REGISTRY_FALLBACK_MEMO_MS + 1;
|
|
382
|
+
serve(registryWidget({ ae: ['footer'] }));
|
|
383
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
384
|
+
expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
|
|
385
|
+
expect(registryReads()).toBe(3);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('uses the extra attempt when it answers', async () => {
|
|
389
|
+
await foundThenLapse();
|
|
390
|
+
let attempts = 0;
|
|
391
|
+
serve(() => {
|
|
392
|
+
attempts += 1;
|
|
393
|
+
return attempts === 1 ? undefined : registryWidget({ ae: ['footer'] });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
397
|
+
expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
|
|
398
|
+
expect(attempts).toBe(2);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('lets a found registry without the entry remove it', async () => {
|
|
402
|
+
await foundThenLapse();
|
|
403
|
+
serve(registryWidget({ ae: [] }));
|
|
404
|
+
|
|
405
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
406
|
+
expect(registryReads()).toBe(1);
|
|
407
|
+
|
|
408
|
+
// The new registry is what a later failure falls back to.
|
|
409
|
+
newRequest();
|
|
410
|
+
now += REGION_REGISTRY_MEMO_MS + 1;
|
|
411
|
+
serve(undefined);
|
|
412
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('serves the last found registry when the read turns unreadable', async () => {
|
|
416
|
+
await foundThenLapse();
|
|
417
|
+
serve({ attributes: { theme_regions: { value: '{nope', kwargs: {} } } });
|
|
418
|
+
|
|
419
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
420
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
421
|
+
expect(warnSpy.mock.calls[0][0]).toContain('last readable registry');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('serves the last found registry when the read throws', async () => {
|
|
425
|
+
await foundThenLapse();
|
|
426
|
+
getWidgetData.mockRejectedValue(new Error('commerce is down'));
|
|
427
|
+
|
|
428
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
429
|
+
expect(registryReads()).toBe(2);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('gives up on a last found registry older than 15 minutes', async () => {
|
|
433
|
+
serve(AE_HEADER);
|
|
434
|
+
await resolveRegionSlug('header');
|
|
435
|
+
|
|
436
|
+
newRequest();
|
|
437
|
+
now = T0 + REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS + 1;
|
|
438
|
+
serve(undefined);
|
|
439
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
440
|
+
|
|
441
|
+
// A locale that has had a registry re-checks soon, not after a minute.
|
|
442
|
+
const missedAt = now;
|
|
443
|
+
newRequest();
|
|
444
|
+
now = missedAt + REGION_REGISTRY_FALLBACK_MEMO_MS + 1;
|
|
445
|
+
serve(AE_HEADER);
|
|
446
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('keeps what each locale found to itself', async () => {
|
|
450
|
+
serve(AE_HEADER);
|
|
451
|
+
await resolveRegionSlug('header');
|
|
452
|
+
|
|
453
|
+
newRequest();
|
|
454
|
+
ServerVariables.locale = 'ar-ae';
|
|
455
|
+
getWidgetData.mockClear();
|
|
456
|
+
serve(undefined);
|
|
457
|
+
expect(await resolveRegionSlug('header')).toBe('header');
|
|
458
|
+
// Never found for 'ar-ae': a miss is not retried.
|
|
459
|
+
expect(registryReads()).toBe(1);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('falls back for a preview too, without memoising anything', async () => {
|
|
463
|
+
serve(AE_HEADER);
|
|
464
|
+
await resolveRegionSlug('header');
|
|
465
|
+
|
|
466
|
+
newRequest();
|
|
467
|
+
ServerVariables.previewId = 'draft';
|
|
468
|
+
draftMode.mockResolvedValue({ isEnabled: true });
|
|
469
|
+
appFetch.mockResolvedValue(undefined);
|
|
470
|
+
expect(await resolveRegionSlug('header')).toBe('header-region-ae');
|
|
471
|
+
expect(appFetch).toHaveBeenCalledTimes(2);
|
|
472
|
+
|
|
473
|
+
newRequest();
|
|
474
|
+
await resolveRegionSlug('header');
|
|
475
|
+
expect(appFetch).toHaveBeenCalledTimes(4);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('readMarketPlaceholderCopy', () => {
|
|
480
|
+
const copy = (layout: unknown) => ({
|
|
481
|
+
slug: 'header-region-ae',
|
|
482
|
+
attributes: { theme_editor_placeholder: layout }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it.each([
|
|
486
|
+
['a wrapped layout', { value: '[{"section_slug":"sec-ae"}]', kwargs: {} }],
|
|
487
|
+
['a wrapped empty layout', { value: '[]', kwargs: {} }],
|
|
488
|
+
['a bare empty layout', '[]'],
|
|
489
|
+
['a parsed layout', [{ section_slug: 'sec-ae' }]]
|
|
490
|
+
])('returns a listed copy with %s', async (_label, layout) => {
|
|
491
|
+
const answer = copy(layout);
|
|
492
|
+
serve(AE_HEADER, (slug) => (slug === 'header-region-ae' ? answer : null));
|
|
493
|
+
|
|
494
|
+
expect(await readMarketPlaceholderCopy('header')).toBe(answer);
|
|
495
|
+
expect(readsOf('header-region-ae')).toBe(1);
|
|
496
|
+
expect(readsOf('header')).toBe(0);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('gives up on a listed copy that answers nothing after the chrome retries', async () => {
|
|
500
|
+
serve(AE_HEADER);
|
|
501
|
+
|
|
502
|
+
expect(await readMarketPlaceholderCopy('header')).toBeNull();
|
|
503
|
+
expect(readsOf('header-region-ae')).toBe(3);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it.each([
|
|
507
|
+
['a truthy 404 body', { detail: 'Not found.' }],
|
|
508
|
+
['no layout attribute', { slug: 'header-region-ae', attributes: {} }],
|
|
509
|
+
['null attributes', { slug: 'header-region-ae', attributes: null }],
|
|
510
|
+
['a wrapped null layout', copy({ value: null, kwargs: {} })]
|
|
511
|
+
])('never lets an answer with %s stand in', async (_label, answer) => {
|
|
512
|
+
serve(AE_HEADER, (slug) => (slug === 'header-region-ae' ? answer : null));
|
|
513
|
+
|
|
514
|
+
expect(await readMarketPlaceholderCopy('header')).toBeNull();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('answers null when the copy read throws', async () => {
|
|
518
|
+
serve(AE_HEADER, (slug) => {
|
|
519
|
+
if (slug === 'header-region-ae') throw new Error('commerce is down');
|
|
520
|
+
return null;
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(await readMarketPlaceholderCopy('header')).toBeNull();
|
|
524
|
+
expect(readsOf('header-region-ae')).toBe(3);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('never probes a copy the registry does not list', async () => {
|
|
528
|
+
serve(AE_HEADER);
|
|
529
|
+
|
|
530
|
+
expect(await readMarketPlaceholderCopy('footer')).toBeNull();
|
|
531
|
+
expect(readsOf('footer-region-ae')).toBe(0);
|
|
532
|
+
expect(readsOf('footer')).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe('withRegionDataSources', () => {
|
|
537
|
+
const themeConfig = [
|
|
538
|
+
{ id: 'ds-1', name: 'Default picks' },
|
|
539
|
+
{ id: 'ds-ae-1', name: 'AE picks (republished)', region: 'ae' },
|
|
540
|
+
{ id: 'ds-2', name: 'Default banners' }
|
|
541
|
+
];
|
|
542
|
+
const own = [
|
|
543
|
+
{
|
|
544
|
+
id: 'ds-ae-1',
|
|
545
|
+
name: 'AE picks (as this copy last saw it)',
|
|
546
|
+
region: 'ae'
|
|
547
|
+
},
|
|
548
|
+
{ id: 'ds-ae-2', name: 'AE banners', region: 'ae' }
|
|
549
|
+
];
|
|
550
|
+
const widget = (value: unknown) => ({
|
|
551
|
+
slug: 'header-region-ae',
|
|
552
|
+
attributes: { theme_region_dataSources: value }
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it.each([
|
|
556
|
+
['no widget', null],
|
|
557
|
+
['an undefined widget', undefined],
|
|
558
|
+
['a widget without attributes', { slug: 'header' }],
|
|
559
|
+
['null attributes', { slug: 'header', attributes: null }],
|
|
560
|
+
['no records attribute', { slug: 'header', attributes: {} }],
|
|
561
|
+
['a null attribute', widget(null)],
|
|
562
|
+
['malformed JSON', widget('[{nope')],
|
|
563
|
+
['a wrapped malformed value', widget({ value: '{', kwargs: {} })],
|
|
564
|
+
['an object instead of a list', widget('{"id":"ds-ae-1"}')],
|
|
565
|
+
['an empty list', widget('[]')],
|
|
566
|
+
[
|
|
567
|
+
'records without a usable id',
|
|
568
|
+
widget([null, 'ds-ae-1', { id: '' }, { id: 7 }])
|
|
569
|
+
]
|
|
570
|
+
])('returns the same list for %s', (_label, placeholderWidget) => {
|
|
571
|
+
expect(withRegionDataSources(themeConfig, placeholderWidget)).toBe(
|
|
572
|
+
themeConfig
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it.each([
|
|
577
|
+
['a JSON string', JSON.stringify(own)],
|
|
578
|
+
['the commerce wrapping', { value: JSON.stringify(own), kwargs: {} }],
|
|
579
|
+
['a parsed list', own]
|
|
580
|
+
])(
|
|
581
|
+
'adds the records theme-config lacks from %s, theme-config winning',
|
|
582
|
+
(_label, value) => {
|
|
583
|
+
const snapshot = JSON.parse(JSON.stringify(themeConfig));
|
|
584
|
+
|
|
585
|
+
expect(withRegionDataSources(themeConfig, widget(value))).toEqual([
|
|
586
|
+
{ id: 'ds-1', name: 'Default picks' },
|
|
587
|
+
{ id: 'ds-ae-1', name: 'AE picks (republished)', region: 'ae' },
|
|
588
|
+
{ id: 'ds-2', name: 'Default banners' },
|
|
589
|
+
{ id: 'ds-ae-2', name: 'AE banners', region: 'ae' }
|
|
590
|
+
]);
|
|
591
|
+
expect(themeConfig).toEqual(snapshot);
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
it('returns the same list when theme-config already holds every record of the copy', () => {
|
|
596
|
+
const held = [own[0]];
|
|
597
|
+
expect(withRegionDataSources(themeConfig, widget(held))).toBe(themeConfig);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('keeps the first of two records with one id', () => {
|
|
601
|
+
const merged = withRegionDataSources(
|
|
602
|
+
[],
|
|
603
|
+
widget([
|
|
604
|
+
{ id: 'ds-ae-1', name: 'first' },
|
|
605
|
+
{ id: 'ds-ae-1', name: 'second' }
|
|
606
|
+
])
|
|
607
|
+
);
|
|
608
|
+
expect(merged).toEqual([{ id: 'ds-ae-1', name: 'first' }]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('keeps records theme-config holds without an id', () => {
|
|
612
|
+
const odd = { name: 'no id' };
|
|
613
|
+
expect(withRegionDataSources([odd], widget(own))).toEqual([odd, ...own]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('still serves the copy when the theme-config list is not a list', () => {
|
|
617
|
+
expect(
|
|
618
|
+
withRegionDataSources({} as unknown as unknown[], widget(own))
|
|
619
|
+
).toEqual(own);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import settings from 'settings';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ZERO-5391 — the two locale settings the theme editor needs before it can
|
|
5
|
+
* offer per-country content, announced on PLACEHOLDERS_AVAILABLE beside
|
|
6
|
+
* `contentCapabilities` (see `placeholder-registry.ts`).
|
|
7
|
+
*
|
|
8
|
+
* The editor moves its canvas from one country to another by switching the
|
|
9
|
+
* preview's locale, and it links a country's page by localising the page
|
|
10
|
+
* path. Both depend on facts only the storefront's settings hold:
|
|
11
|
+
*
|
|
12
|
+
* - `localeUrlStrategy` under 'subdomain' a locale switch moves the preview
|
|
13
|
+
* to another ORIGIN, which the editor cannot follow.
|
|
14
|
+
* - `defaultLocale` the hide-* strategies serve the default locale
|
|
15
|
+
* without a path prefix, and a prefixed default-locale
|
|
16
|
+
* URL does not resolve.
|
|
17
|
+
*
|
|
18
|
+
* Read when a frame is built, never at module scope, and never allowed to
|
|
19
|
+
* throw: the frame these ride is the one every editor load waits for, and
|
|
20
|
+
* losing it to a settings read would cost far more than these two fields.
|
|
21
|
+
*
|
|
22
|
+
* Client-safe. `settings` is the brand app's module, already in the client
|
|
23
|
+
* graph (the core's localization provider and pz-theme's find-in-store
|
|
24
|
+
* section import it). Never import the server region reader
|
|
25
|
+
* (`region-source.ts`) from here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
type LocalizationSettings = {
|
|
29
|
+
localeUrlStrategy?: unknown;
|
|
30
|
+
defaultLocaleValue?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const localization = (): LocalizationSettings | undefined => {
|
|
34
|
+
try {
|
|
35
|
+
return (settings as { localization?: LocalizationSettings } | undefined)
|
|
36
|
+
?.localization;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `settings.localization.localeUrlStrategy`, or 'hide-default-locale' when it
|
|
44
|
+
* is unset — the strategy the core's locale middleware routes by in that case
|
|
45
|
+
* (`middlewares/locale.ts`), so the editor is told what the storefront does.
|
|
46
|
+
*/
|
|
47
|
+
export const localeUrlStrategyForEditor = (): string => {
|
|
48
|
+
const value = localization()?.localeUrlStrategy;
|
|
49
|
+
return typeof value === 'string' && value ? value : 'hide-default-locale';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `settings.localization.defaultLocaleValue`, or '' when it is not a string.
|
|
54
|
+
* The core's locale middleware refuses to route without one, so '' only ever
|
|
55
|
+
* leaves a misconfigured build.
|
|
56
|
+
*/
|
|
57
|
+
export const defaultLocaleForEditor = (): string => {
|
|
58
|
+
const value = localization()?.defaultLocaleValue;
|
|
59
|
+
return typeof value === 'string' ? value : '';
|
|
60
|
+
};
|