@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,961 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+
4
+ const push = jest.fn();
5
+ const searchParams = new URLSearchParams('');
6
+
7
+ jest.mock('next/navigation', () => ({
8
+ __esModule: true,
9
+ usePathname: () => '/shoes',
10
+ useSearchParams: () => searchParams,
11
+ useRouter: () => ({ push, replace: jest.fn(), refresh: jest.fn() })
12
+ }));
13
+
14
+ import ListingPaginationBlock from '../blocks/listing-pagination-block';
15
+ import ProductGridBlock from '../blocks/product-grid-block';
16
+ import SortSelectBlock from '../blocks/sort-select-block';
17
+ import { ThemePageContextProvider } from '../theme-page-context';
18
+
19
+ type Props = Record<string, unknown>;
20
+
21
+ const listingContext = (over: Record<string, unknown> = {}) => ({
22
+ listing: {
23
+ title: 'Shoes',
24
+ path: '/shoes',
25
+ products: [
26
+ { name: 'One', url: '/p/1', price: '10', pk: 1 },
27
+ { name: 'Two', url: '/p/2', price: '20', pk: 2 }
28
+ ],
29
+ resultCount: 6,
30
+ facets: [],
31
+ sorters: [],
32
+ pagination: { current_page: 1, num_pages: 3, page_size: 2, count: 6 },
33
+ breadcrumbs: [],
34
+ ...over
35
+ }
36
+ });
37
+
38
+ const blockProps = (properties: Props = {}, over: Props = {}) =>
39
+ ({
40
+ block: {
41
+ id: 'blk-1',
42
+ type: 'listing-pagination',
43
+ label: 'Pagination',
44
+ order: 0,
45
+ hidden: false,
46
+ value: '',
47
+ properties,
48
+ styles: {},
49
+ blocks: []
50
+ },
51
+ placeholderId: 'ph',
52
+ sectionId: 'sec',
53
+ isDesigner: false,
54
+ ...over
55
+ } as never);
56
+
57
+ const renderPagination = (
58
+ properties: Props = {},
59
+ over: Props = {},
60
+ context = listingContext()
61
+ ) =>
62
+ render(
63
+ <ThemePageContextProvider value={context}>
64
+ <div>
65
+ <div data-product-grid="">
66
+ <a href="/p/1" data-product-card="">
67
+ One
68
+ </a>
69
+ <a href="/p/2" data-product-card="">
70
+ Two
71
+ </a>
72
+ </div>
73
+ <ListingPaginationBlock {...blockProps(properties, over)} />
74
+ </div>
75
+ </ThemePageContextProvider>
76
+ );
77
+
78
+ /** The next page exactly as the server renders it — style element included. */
79
+ const pageHtml = (ids: number[]) =>
80
+ `<!doctype html><html><body><main><div data-product-grid="" data-product-grid-id="g">` +
81
+ `<style>[data-product-grid-id="g"]{grid-template-columns:repeat(9,minmax(0,1fr));}</style>` +
82
+ ids
83
+ .map((id) => `<a href="/p/${id}" data-product-card="">Item ${id}</a>`)
84
+ .join('') +
85
+ `</div></main></body></html>`;
86
+
87
+ const okResponse = (html: string) =>
88
+ Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(html) });
89
+
90
+ /**
91
+ * Clicks a real anchor and reports whether the block intercepted it.
92
+ *
93
+ * The document listener runs after React's (React delegates at the root
94
+ * container, which is inside the body), so it reads the verdict and only then
95
+ * stops jsdom from acting on an href it cannot navigate to — the pending
96
+ * navigation timer it would otherwise leave behind outlives the test.
97
+ */
98
+ const clickLink = (element: HTMLElement): boolean => {
99
+ let intercepted = false;
100
+
101
+ const inspect = (event: Event) => {
102
+ intercepted = event.defaultPrevented;
103
+ event.preventDefault();
104
+ };
105
+
106
+ document.addEventListener('click', inspect);
107
+ fireEvent.click(element);
108
+ document.removeEventListener('click', inspect);
109
+
110
+ return intercepted;
111
+ };
112
+
113
+ class MockObserver {
114
+ static instances: MockObserver[] = [];
115
+ callback: IntersectionObserverCallback;
116
+ options: IntersectionObserverInit | undefined;
117
+ elements: Element[] = [];
118
+ disconnected = false;
119
+
120
+ constructor(
121
+ callback: IntersectionObserverCallback,
122
+ options?: IntersectionObserverInit
123
+ ) {
124
+ this.callback = callback;
125
+ this.options = options;
126
+ MockObserver.instances.push(this);
127
+ }
128
+
129
+ observe(element: Element) {
130
+ this.elements.push(element);
131
+ }
132
+
133
+ unobserve() {}
134
+
135
+ disconnect() {
136
+ this.disconnected = true;
137
+ }
138
+
139
+ takeRecords() {
140
+ return [];
141
+ }
142
+
143
+ fire() {
144
+ this.callback(
145
+ [{ isIntersecting: true } as IntersectionObserverEntry],
146
+ this as unknown as IntersectionObserver
147
+ );
148
+ }
149
+
150
+ static get live() {
151
+ return MockObserver.instances.filter((instance) => !instance.disconnected);
152
+ }
153
+ }
154
+
155
+ const originalObserver = window.IntersectionObserver;
156
+
157
+ beforeEach(() => {
158
+ push.mockClear();
159
+ MockObserver.instances = [];
160
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
161
+ MockObserver;
162
+ (global as unknown as Record<string, unknown>).fetch = jest.fn(() =>
163
+ okResponse(pageHtml([3, 4]))
164
+ );
165
+ window.history.replaceState(null, '', '/shoes');
166
+ });
167
+
168
+ afterEach(() => {
169
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
170
+ originalObserver;
171
+ });
172
+
173
+ /**
174
+ * Byte-identity goldens.
175
+ *
176
+ * Append, infinite scroll and the count readout all arrive switched off, so a
177
+ * composition that never asks for them has to produce the exact same bytes it
178
+ * produced before they existed. These strings are that promise written down:
179
+ * any change to the default markup — an attribute, an element, an inline
180
+ * style, even the order of two declarations — fails here rather than on a
181
+ * brand's storefront.
182
+ *
183
+ * They are the SERVER render, not jsdom's `innerHTML`: jsdom re-serialises CSS
184
+ * (lowercasing `currentColor`, dropping declarations it cannot parse), which
185
+ * would hide exactly the kind of drift this is meant to catch. These are the
186
+ * bytes a crawler and a cold visit receive.
187
+ */
188
+ const ssr = (element: React.ReactElement): string => {
189
+ // react-dom's browser server build reaches for MessageChannel and
190
+ // TextEncoder at import time; jsdom's global has neither, so Node's own are
191
+ // handed over. The require has to be lazy for them to land first.
192
+ const scope = globalThis as unknown as Record<string, unknown>;
193
+ /* eslint-disable @typescript-eslint/no-var-requires */
194
+ if (typeof scope.MessageChannel === 'undefined') {
195
+ // Deliberately inert rather than Node's own: a real MessagePort refs the
196
+ // event loop and the whole run hangs at teardown. renderToStaticMarkup is
197
+ // synchronous and never posts through it.
198
+ scope.MessageChannel = class {
199
+ port1 = { onmessage: null, postMessage() {}, close() {} };
200
+ port2 = { onmessage: null, postMessage() {}, close() {} };
201
+ };
202
+ }
203
+ if (typeof scope.TextEncoder === 'undefined') {
204
+ scope.TextEncoder = require('util').TextEncoder;
205
+ }
206
+
207
+ const { renderToStaticMarkup } = require('react-dom/server');
208
+ /* eslint-enable @typescript-eslint/no-var-requires */
209
+ return renderToStaticMarkup(element) as string;
210
+ };
211
+
212
+ const GOLDEN_PAGES =
213
+ '<nav data-listing-pagination="" data-pagination-mode="pages" aria-label="Pagination" data-part="list" style="display:flex;align-items:center;gap:var(--listing-pagination-gap, 8px);flex-wrap:wrap"><a href="/shoes" data-pagination-page="1" aria-current="page" data-part="page" data-state="selected" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:1;font-weight:600">1</a><a href="/shoes?page=2" data-pagination-page="2" data-part="page" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">2</a><a href="/shoes?page=3" data-pagination-page="3" data-part="page" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">3</a><a href="/shoes?page=2" rel="next" data-pagination-next="" data-part="next" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">Next</a></nav>';
214
+
215
+ const GOLDEN_LOADMORE =
216
+ '<nav data-listing-pagination="" data-pagination-mode="loadmore" data-part="list"><a href="/shoes?page=2" rel="next" data-listing-load-more="" data-part="load-more" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">Load more</a></nav>';
217
+
218
+ const ssrPagination = (properties: Props = {}) =>
219
+ ssr(
220
+ <ThemePageContextProvider value={listingContext()}>
221
+ <ListingPaginationBlock {...blockProps(properties)} />
222
+ </ThemePageContextProvider>
223
+ );
224
+
225
+ describe('pagination — the default render is untouched', () => {
226
+ it('renders page links exactly as it always has', () => {
227
+ expect(ssrPagination()).toBe(GOLDEN_PAGES);
228
+ });
229
+
230
+ it('renders load more as a plain link to the next page', () => {
231
+ expect(ssrPagination({ mode: 'loadmore' })).toBe(GOLDEN_LOADMORE);
232
+ });
233
+
234
+ it('renders load more identically whether or not append exists', () => {
235
+ // The enhancement is invisible to the server: same bytes, so the crawl
236
+ // path and the no-javascript path are exactly what they were.
237
+ expect(ssrPagination({ mode: 'loadmore', append: false })).toBe(
238
+ GOLDEN_LOADMORE
239
+ );
240
+ });
241
+
242
+ it('lets that link navigate — no fetch, no intercepted click', () => {
243
+ renderPagination({ mode: 'loadmore' });
244
+
245
+ expect(clickLink(screen.getByText('Load more'))).toBe(false);
246
+ expect(global.fetch).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('arms no observer and touches no history until append is asked for', () => {
250
+ const replaceSpy = jest.spyOn(window.history, 'replaceState');
251
+ renderPagination({ mode: 'loadmore' });
252
+
253
+ expect(MockObserver.instances).toHaveLength(0);
254
+ expect(replaceSpy).not.toHaveBeenCalled();
255
+ replaceSpy.mockRestore();
256
+ });
257
+ });
258
+
259
+ describe('pagination — append', () => {
260
+ const appendProps = {
261
+ mode: 'loadmore',
262
+ append: true
263
+ };
264
+
265
+ it("grafts the next page's own server markup into the live list", async () => {
266
+ const { container } = renderPagination(appendProps);
267
+
268
+ fireEvent.click(screen.getByText('Load more'));
269
+
270
+ await waitFor(() =>
271
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
272
+ );
273
+
274
+ expect(global.fetch).toHaveBeenCalledWith(
275
+ '/shoes?page=2',
276
+ expect.objectContaining({ credentials: 'same-origin' })
277
+ );
278
+ // The rows the server rendered, not rows re-derived here: identical
279
+ // markup, so a designer-authored card layout survives the append.
280
+ expect(screen.getByText('Item 3')).toBeInTheDocument();
281
+ expect(screen.getByText('Item 4')).toBeInTheDocument();
282
+ });
283
+
284
+ it('never copies the list stylesheet, which would outrank the live one', async () => {
285
+ const { container } = renderPagination(appendProps);
286
+
287
+ fireEvent.click(screen.getByText('Load more'));
288
+
289
+ await waitFor(() =>
290
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
291
+ );
292
+ expect(container.querySelectorAll('style')).toHaveLength(0);
293
+ });
294
+
295
+ it('keeps the cursor honest with replaceState, never pushState', async () => {
296
+ const replaceSpy = jest.spyOn(window.history, 'replaceState');
297
+ const pushSpy = jest.spyOn(window.history, 'pushState');
298
+
299
+ renderPagination(appendProps);
300
+ fireEvent.click(screen.getByText('Load more'));
301
+
302
+ await waitFor(() =>
303
+ expect(replaceSpy).toHaveBeenCalledWith(null, '', '/shoes?page=2')
304
+ );
305
+ // One entry per append would turn the back button into a walk through
306
+ // every batch instead of a way off the listing.
307
+ expect(pushSpy).not.toHaveBeenCalled();
308
+
309
+ replaceSpy.mockRestore();
310
+ pushSpy.mockRestore();
311
+ });
312
+
313
+ it('records which page a row came from and restores it on click', async () => {
314
+ const { container } = renderPagination(appendProps);
315
+
316
+ fireEvent.click(screen.getByText('Load more'));
317
+ await waitFor(() =>
318
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
319
+ );
320
+
321
+ const replaceSpy = jest.spyOn(window.history, 'replaceState');
322
+
323
+ clickLink(screen.getByText('Item 3'));
324
+ expect(replaceSpy).toHaveBeenCalledWith(null, '', '/shoes?page=2');
325
+
326
+ // A row from the server-rendered first page takes the shopper back there,
327
+ // not to the deepest page that happens to be loaded.
328
+ clickLink(screen.getByText('One'));
329
+ expect(replaceSpy).toHaveBeenLastCalledWith(null, '', '/shoes');
330
+
331
+ replaceSpy.mockRestore();
332
+ });
333
+
334
+ it('walks to the last page and then stops asking', async () => {
335
+ const { container } = renderPagination(appendProps);
336
+
337
+ fireEvent.click(screen.getByText('Load more'));
338
+ await waitFor(() =>
339
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
340
+ );
341
+
342
+ (global.fetch as jest.Mock).mockImplementationOnce(() =>
343
+ okResponse(pageHtml([5, 6]))
344
+ );
345
+ fireEvent.click(screen.getByText('Load more'));
346
+ await waitFor(() =>
347
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(6)
348
+ );
349
+
350
+ // Page 3 of 3: the affordance is gone, so there is nothing left to press.
351
+ expect(screen.queryByText('Load more')).not.toBeInTheDocument();
352
+ });
353
+
354
+ it('offers the shopper a way out of a failed page', async () => {
355
+ (global.fetch as jest.Mock).mockImplementationOnce(() =>
356
+ Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('') })
357
+ );
358
+
359
+ const { container } = renderPagination({
360
+ ...appendProps,
361
+ errorLabel: 'Nope',
362
+ retryLabel: 'Retry'
363
+ });
364
+
365
+ fireEvent.click(screen.getByText('Load more'));
366
+ await screen.findByText('Nope');
367
+
368
+ fireEvent.click(screen.getByText('Retry'));
369
+ await waitFor(() =>
370
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
371
+ );
372
+ expect(screen.queryByText('Nope')).not.toBeInTheDocument();
373
+ });
374
+
375
+ it('is completely inert in the designer', async () => {
376
+ renderPagination(
377
+ { ...appendProps, appendTrigger: 'scroll' },
378
+ { isDesigner: true }
379
+ );
380
+
381
+ // No sentinel is watched, and the link is inert rather than appending.
382
+ expect(MockObserver.instances).toHaveLength(0);
383
+
384
+ expect(clickLink(screen.getByText('Load more'))).toBe(true);
385
+ expect(global.fetch).not.toHaveBeenCalled();
386
+ });
387
+ });
388
+
389
+ describe('pagination — the infinite scroll sentinel', () => {
390
+ const scrollProps = {
391
+ mode: 'loadmore',
392
+ append: true,
393
+ appendTrigger: 'scroll',
394
+ prefetchOffset: 400
395
+ };
396
+
397
+ it('watches a sentinel with the designer-chosen lead', () => {
398
+ renderPagination(scrollProps);
399
+
400
+ expect(MockObserver.live).toHaveLength(1);
401
+ expect(MockObserver.live[0].options).toEqual({ rootMargin: '400px 0px' });
402
+ expect(MockObserver.live[0].elements[0]).toHaveAttribute(
403
+ 'data-listing-load-sentinel'
404
+ );
405
+ });
406
+
407
+ it('loads one page per crossing and never two at once', async () => {
408
+ const { container } = renderPagination(scrollProps);
409
+
410
+ await act(async () => {
411
+ MockObserver.live[0].fire();
412
+ // A second crossing while the first request is still open must not
413
+ // queue a duplicate page.
414
+ MockObserver.instances[0].fire();
415
+ });
416
+
417
+ await waitFor(() =>
418
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
419
+ );
420
+ expect(global.fetch).toHaveBeenCalledTimes(1);
421
+ });
422
+
423
+ it('stands down at the end of the list rather than firing forever', async () => {
424
+ const { container } = renderPagination(scrollProps);
425
+
426
+ await act(async () => {
427
+ MockObserver.live[0].fire();
428
+ });
429
+ await waitFor(() =>
430
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
431
+ );
432
+
433
+ (global.fetch as jest.Mock).mockImplementationOnce(() =>
434
+ okResponse(pageHtml([5, 6]))
435
+ );
436
+ await act(async () => {
437
+ MockObserver.live[0].fire();
438
+ });
439
+ await waitFor(() =>
440
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(6)
441
+ );
442
+
443
+ expect(MockObserver.live).toHaveLength(0);
444
+ });
445
+
446
+ it('stands down after a failure until the shopper retries', async () => {
447
+ (global.fetch as jest.Mock).mockImplementationOnce(() =>
448
+ Promise.reject(new Error('offline'))
449
+ );
450
+
451
+ renderPagination({ ...scrollProps, retryLabel: 'Retry' });
452
+
453
+ await act(async () => {
454
+ MockObserver.live[0].fire();
455
+ });
456
+
457
+ await screen.findByText('Retry');
458
+ expect(MockObserver.live).toHaveLength(0);
459
+ });
460
+ });
461
+
462
+ describe('pagination — the count readout', () => {
463
+ it('publishes the numbers as well as the sentence', () => {
464
+ renderPagination({ showStatus: true });
465
+
466
+ const status = document.querySelector('[data-part="status"]') as HTMLElement;
467
+ expect(status).toHaveTextContent('Showing 2 of 6');
468
+ expect(status).toHaveAttribute('data-listing-shown', '2');
469
+ expect(status).toHaveAttribute('data-listing-total', '6');
470
+ expect(status).toHaveAttribute('aria-live', 'polite');
471
+ });
472
+
473
+ it('counts what is on screen after an append', async () => {
474
+ const { container } = renderPagination({
475
+ mode: 'loadmore',
476
+ append: true,
477
+ showStatus: true
478
+ });
479
+
480
+ fireEvent.click(screen.getByText('Load more'));
481
+ await waitFor(() =>
482
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
483
+ );
484
+
485
+ expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
486
+ 'Showing 4 of 6'
487
+ );
488
+ });
489
+
490
+ it('states a range from the page the shopper actually landed on', () => {
491
+ renderPagination(
492
+ { showStatus: true, statusTemplate: '{from}–{to} of {total}' },
493
+ {},
494
+ listingContext({
495
+ pagination: { current_page: 2, num_pages: 3, page_size: 2, count: 6 }
496
+ })
497
+ );
498
+
499
+ expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
500
+ '3–4 of 6'
501
+ );
502
+ });
503
+ });
504
+
505
+ /* -------------------------------------------------------------------------- */
506
+
507
+ /**
508
+ * Append against the REAL grid rather than a hand-written list.
509
+ *
510
+ * Both switches the grid and the pagination own are first-class options, and
511
+ * the failures below only exist where they meet: a grid that pins a
512
+ * merchandising tile by index, and a pagination block that grafts another
513
+ * page's cells into it. A fixture list cannot reproduce either, because the
514
+ * fixture has no tile and writes no rules.
515
+ */
516
+
517
+ /** A campaign tile the merchandiser asked to repeat on every page. */
518
+ const everyPageTile = {
519
+ id: 'tile-1',
520
+ type: 'group',
521
+ label: 'Campaign',
522
+ order: 0,
523
+ hidden: false,
524
+ value: '',
525
+ properties: {
526
+ role: { desktop: 'grid-insertion' },
527
+ insertPosition: { desktop: 2 },
528
+ insertPages: { desktop: 'all' }
529
+ },
530
+ styles: {},
531
+ blocks: []
532
+ };
533
+
534
+ const gridProps = (blocks: unknown[] = [everyPageTile]) =>
535
+ ({
536
+ block: {
537
+ id: 'grid-1',
538
+ type: 'product-grid',
539
+ label: 'Grid',
540
+ order: 0,
541
+ hidden: false,
542
+ value: '',
543
+ properties: {},
544
+ styles: {},
545
+ blocks
546
+ },
547
+ placeholderId: 'ph',
548
+ sectionId: 'sec',
549
+ isDesigner: false
550
+ } as never);
551
+
552
+ const gridListing = (page: number, ids: number[]) => ({
553
+ listing: {
554
+ title: 'Shoes',
555
+ path: '/shoes',
556
+ products: ids.map((id) => ({
557
+ name: `Item ${id}`,
558
+ url: `/p/${id}`,
559
+ price: String(id * 10),
560
+ pk: id
561
+ })),
562
+ facets: [],
563
+ sorters: [],
564
+ pagination: { current_page: page, num_pages: 3, page_size: 2, count: 6 }
565
+ }
566
+ });
567
+
568
+ /** Page N exactly as the server renders it — the real grid, tile and all. */
569
+ const gridPageHtml = (page: number, ids: number[]) =>
570
+ '<!doctype html><html><body><main>' +
571
+ ssr(
572
+ <ThemePageContextProvider value={gridListing(page, ids)}>
573
+ <ProductGridBlock {...gridProps()} />
574
+ </ThemePageContextProvider>
575
+ ) +
576
+ '</main></body></html>';
577
+
578
+ const renderGrid = (properties: Props) => {
579
+ const context = gridListing(1, [1, 2]);
580
+
581
+ return render(
582
+ <ThemePageContextProvider value={context}>
583
+ <div>
584
+ <ProductGridBlock {...gridProps()} />
585
+ <ListingPaginationBlock {...blockProps(properties)} />
586
+ </div>
587
+ </ThemePageContextProvider>
588
+ );
589
+ };
590
+
591
+ /** The grid's own stylesheet, which is the only one on the page. */
592
+ const gridSheet = (container: HTMLElement): string =>
593
+ container.querySelector('[data-product-grid] > style')?.textContent ?? '';
594
+
595
+ /** Every cell-pinning rule the grid wrote, reduced to its bare selector. */
596
+ const pinSelectors = (css: string): string[] =>
597
+ Array.from(css.matchAll(/([^{}@]+)\{grid-column:[^{}]*\}/g)).map((match) =>
598
+ match[1].trim()
599
+ );
600
+
601
+ describe('pagination — appending into the real product grid', () => {
602
+ const appendProps = { mode: 'loadmore', append: true };
603
+
604
+ const appendPage = async (
605
+ container: HTMLElement,
606
+ page: number,
607
+ ids: number[],
608
+ expectedCards: number
609
+ ) => {
610
+ (global.fetch as jest.Mock).mockImplementationOnce(() =>
611
+ okResponse(gridPageHtml(page, ids))
612
+ );
613
+ fireEvent.click(screen.getByText('Load more'));
614
+ await waitFor(() =>
615
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(
616
+ expectedCards
617
+ )
618
+ );
619
+ };
620
+
621
+ it('never lets one pinning rule place two cells', async () => {
622
+ const { container } = renderGrid(appendProps);
623
+ await appendPage(container, 2, [3, 4], 4);
624
+
625
+ // Page two's tile is a cell of the same grid now. A rule page one wrote
626
+ // for "the tile at index 0" that also reaches page two's puts both in one
627
+ // cell, stacked on each other and over whatever was there.
628
+ const selectors = pinSelectors(gridSheet(container));
629
+ expect(selectors.length).toBeGreaterThan(0);
630
+ selectors.forEach((selector) => {
631
+ expect(container.querySelectorAll(selector)).toHaveLength(1);
632
+ });
633
+ });
634
+
635
+ it('leaves every cell the shopper already saw exactly where it was', async () => {
636
+ const { container } = renderGrid(appendProps);
637
+ const before = Array.from(
638
+ container.querySelector('[data-product-grid]')?.children ?? []
639
+ );
640
+
641
+ await appendPage(container, 2, [3, 4], 4);
642
+
643
+ const after = Array.from(
644
+ container.querySelector('[data-product-grid]')?.children ?? []
645
+ );
646
+ // Same nodes, same order, nothing inserted among them: an appended page
647
+ // is additive or it is a regression.
648
+ expect(after.slice(0, before.length)).toEqual(before);
649
+ expect(after.length).toBeGreaterThan(before.length);
650
+ });
651
+
652
+ it('counts the products it appended, not the cells', async () => {
653
+ const { container } = renderGrid({ ...appendProps, showStatus: true });
654
+ await appendPage(container, 2, [3, 4], 4);
655
+
656
+ // Four cards on screen; the tile that came with them is merchandising,
657
+ // not a product the listing is counting down.
658
+ expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
659
+ 'Showing 4 of 6'
660
+ );
661
+ });
662
+
663
+ it('never states a range that runs past the end of the listing', async () => {
664
+ const { container } = renderGrid({
665
+ ...appendProps,
666
+ showStatus: true,
667
+ statusTemplate: '{from}-{to} of {total}'
668
+ });
669
+
670
+ await appendPage(container, 2, [3, 4], 4);
671
+ await appendPage(container, 3, [5, 6], 6);
672
+
673
+ expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
674
+ '1-6 of 6'
675
+ );
676
+ });
677
+ });
678
+
679
+ describe('pagination — the fetched page is untrusted', () => {
680
+ const appendProps = { mode: 'loadmore', append: true };
681
+
682
+ /**
683
+ * A card with a nested block inside it: a lottie, an announcement bar and a
684
+ * recommendation strip all emit their own <style>, and any of them can sit
685
+ * inside a card. The script is what a compromised or mistaken response would
686
+ * carry — cloned out of a DOMParser document it has never started, so it
687
+ * runs the moment it is inserted.
688
+ */
689
+ const hostileHtml = () =>
690
+ '<!doctype html><html><body><main>' +
691
+ '<div data-product-grid="" data-product-grid-id="g">' +
692
+ '<style>[data-product-grid-id="g"]{grid-template-columns:repeat(9,minmax(0,1fr));}</style>' +
693
+ '<a href="/p/3" data-product-card="">Item 3' +
694
+ '<span data-lottie="">' +
695
+ '<style>[data-lottie=""]{display:none;}</style>' +
696
+ '<script>window.__graftEscaped = true;</script>' +
697
+ '<img src="/x.jpg" onerror="window.__graftEscaped = true">' +
698
+ '</span></a>' +
699
+ '<a href="javascript:window.__graftEscaped = true" data-product-card="">' +
700
+ 'Item 4</a>' +
701
+ '</div></main></body></html>';
702
+
703
+ beforeEach(() => {
704
+ delete (window as unknown as Record<string, unknown>).__graftEscaped;
705
+ (global.fetch as jest.Mock).mockImplementation(() =>
706
+ okResponse(hostileHtml())
707
+ );
708
+ });
709
+
710
+ const append = async (container: HTMLElement) => {
711
+ fireEvent.click(screen.getByText('Load more'));
712
+ await waitFor(() =>
713
+ expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
714
+ );
715
+ };
716
+
717
+ it('lets no script cross into the live document', async () => {
718
+ const { container } = renderPagination(appendProps);
719
+ await append(container);
720
+
721
+ // The element's presence IS the defect: a script cloned out of a
722
+ // DOMParser document has never started, so a browser runs it on insertion.
723
+ // jsdom does not, which is why this asserts the DOM and not a side effect.
724
+ expect(container.querySelectorAll('script')).toHaveLength(0);
725
+ expect(
726
+ (window as unknown as Record<string, unknown>).__graftEscaped
727
+ ).toBeUndefined();
728
+ });
729
+
730
+ it('copies no stylesheet at any depth, not just the top one', async () => {
731
+ const { container } = renderPagination(appendProps);
732
+ await append(container);
733
+
734
+ // A nested <style> outranks the live one exactly as a top-level one does;
735
+ // depth was never what made it safe.
736
+ expect(container.querySelectorAll('style')).toHaveLength(0);
737
+ });
738
+
739
+ it('strips the handlers and the script urls off what it does copy', async () => {
740
+ const { container } = renderPagination(appendProps);
741
+ await append(container);
742
+
743
+ const image = container.querySelector('img[src="/x.jpg"]');
744
+ expect(image).not.toBeNull();
745
+ expect(image?.hasAttribute('onerror')).toBe(false);
746
+ expect(
747
+ container.querySelector('a[href^="javascript:"]')
748
+ ).toBeNull();
749
+ });
750
+
751
+ it('keeps text a shopper reads, even when it reads like a scheme', async () => {
752
+ // "JavaScript: The Good Parts" is a product a bookshop really sells, and
753
+ // it normalises to a script scheme. An attribute the browser never
754
+ // resolves cannot execute one, so scrubbing it costs the card its only
755
+ // description — and costs it on the appended page only, so the same
756
+ // product reads differently above and below the fold.
757
+ (global.fetch as jest.Mock).mockImplementation(() =>
758
+ okResponse(
759
+ '<!doctype html><html><body><main>' +
760
+ '<div data-product-grid="" data-product-grid-id="g">' +
761
+ '<a href="/p/3" data-product-card="" ' +
762
+ 'title="JavaScript: The Good Parts">' +
763
+ '<img src="/b.jpg" alt="JavaScript: The Good Parts">Item 3</a>' +
764
+ '<a href="/p/4" data-product-card="">Item 4</a>' +
765
+ '</div></main></body></html>'
766
+ )
767
+ );
768
+
769
+ const { container } = renderPagination(appendProps);
770
+ await append(container);
771
+
772
+ expect(
773
+ container.querySelector('img[src="/b.jpg"]')?.getAttribute('alt')
774
+ ).toBe('JavaScript: The Good Parts');
775
+ expect(
776
+ container.querySelector('a[href="/p/3"]')?.getAttribute('title')
777
+ ).toBe('JavaScript: The Good Parts');
778
+ });
779
+
780
+ it('still grafts the products themselves', async () => {
781
+ const { container } = renderPagination(appendProps);
782
+ await append(container);
783
+
784
+ expect(screen.getByText(/Item 3/)).toBeInTheDocument();
785
+ expect(screen.getByText(/Item 4/)).toBeInTheDocument();
786
+ // The card's own content survives the scrub — only the executable parts
787
+ // are dropped, not the markup around them.
788
+ expect(container.querySelector('[data-lottie]')).not.toBeNull();
789
+ });
790
+ });
791
+
792
+ /* -------------------------------------------------------------------------- */
793
+
794
+ const sortBlockProps = (properties: Props = {}, over: Props = {}) =>
795
+ ({
796
+ block: {
797
+ id: 'sort-1',
798
+ type: 'sort-select',
799
+ label: 'Sort',
800
+ order: 0,
801
+ hidden: false,
802
+ value: '',
803
+ properties,
804
+ styles: {},
805
+ blocks: []
806
+ },
807
+ placeholderId: 'ph',
808
+ sectionId: 'sec',
809
+ isDesigner: false,
810
+ ...over
811
+ } as never);
812
+
813
+ const sorters = [
814
+ { label: 'Newest', value: 'newest', is_selected: false },
815
+ { label: 'Price low to high', value: 'price_asc', is_selected: true },
816
+ { label: 'Price high to low', value: 'price_desc', is_selected: false }
817
+ ];
818
+
819
+ const renderSort = (properties: Props = {}, over: Props = {}) =>
820
+ render(
821
+ <ThemePageContextProvider value={listingContext({ sorters })}>
822
+ <SortSelectBlock {...sortBlockProps(properties, over)} />
823
+ </ThemePageContextProvider>
824
+ );
825
+
826
+ const GOLDEN_SORT_NATIVE =
827
+ '<label data-sort-select="" data-part="group" style="display:inline-flex;align-items:center;gap:8px;color:inherit"><span data-sort-select-label="" data-part="label">Sort by</span><select data-sort-select-input="" data-part="dropdown" aria-label="Sort by" style="border:1px solid currentColor;border-radius:inherit;background:transparent;color:inherit;font:inherit;padding:6px 8px;cursor:pointer"><option value="newest" data-part="option">Newest</option><option value="price_asc" data-part="option" data-state="selected" selected="">Price low to high</option><option value="price_desc" data-part="option">Price high to low</option></select></label>';
828
+
829
+ describe('sort — the native select is untouched', () => {
830
+ it('renders exactly as it always has', () => {
831
+ expect(
832
+ ssr(
833
+ <ThemePageContextProvider value={listingContext({ sorters })}>
834
+ <SortSelectBlock {...sortBlockProps()} />
835
+ </ThemePageContextProvider>
836
+ )
837
+ ).toBe(GOLDEN_SORT_NATIVE);
838
+ });
839
+
840
+ it('drops the page cursor when the sort changes', () => {
841
+ const { container } = renderSort();
842
+
843
+ fireEvent.change(container.querySelector('select') as HTMLSelectElement, {
844
+ target: { value: 'price_desc' }
845
+ });
846
+
847
+ expect(push).toHaveBeenCalledWith('/shoes?sorter=price_desc');
848
+ });
849
+ });
850
+
851
+ describe('sort — the option panel', () => {
852
+ const panelProps = { variant: 'panel' };
853
+
854
+ it('names the applied sort on the trigger', () => {
855
+ renderSort(panelProps);
856
+
857
+ const trigger = screen.getByRole('button');
858
+ expect(trigger).toHaveTextContent('Price low to high');
859
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
860
+ expect(trigger).toHaveAttribute('aria-haspopup', 'listbox');
861
+ });
862
+
863
+ it('composes the trigger text a designer asked for', () => {
864
+ renderSort({ ...panelProps, triggerTemplate: 'Sort: {value}' });
865
+ expect(screen.getByRole('button')).toHaveTextContent(
866
+ 'Sort: Price low to high'
867
+ );
868
+ });
869
+
870
+ it('opens onto the applied option and marks it', () => {
871
+ renderSort(panelProps);
872
+ fireEvent.click(screen.getByRole('button'));
873
+
874
+ const options = screen.getAllByRole('option');
875
+ expect(options).toHaveLength(3);
876
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
877
+ expect(options[0]).toHaveAttribute('aria-selected', 'false');
878
+ // Roving tab focus: exactly one row is reachable with Tab, and it is the
879
+ // one the arrow keys are on.
880
+ expect(options[1]).toHaveAttribute('tabindex', '0');
881
+ expect(options[0]).toHaveAttribute('tabindex', '-1');
882
+ expect(options[1]).toHaveFocus();
883
+ });
884
+
885
+ it('moves through the options with the arrow keys, Home and End', () => {
886
+ renderSort(panelProps);
887
+ fireEvent.click(screen.getByRole('button'));
888
+
889
+ const panel = screen.getByRole('listbox');
890
+ fireEvent.keyDown(panel, { key: 'ArrowDown' });
891
+ expect(screen.getAllByRole('option')[2]).toHaveFocus();
892
+
893
+ // Clamped, not wrapped — the same as a native select.
894
+ fireEvent.keyDown(panel, { key: 'ArrowDown' });
895
+ expect(screen.getAllByRole('option')[2]).toHaveFocus();
896
+
897
+ fireEvent.keyDown(panel, { key: 'Home' });
898
+ expect(screen.getAllByRole('option')[0]).toHaveFocus();
899
+
900
+ fireEvent.keyDown(panel, { key: 'End' });
901
+ expect(screen.getAllByRole('option')[2]).toHaveFocus();
902
+
903
+ fireEvent.keyDown(panel, { key: 'ArrowUp' });
904
+ expect(screen.getAllByRole('option')[1]).toHaveFocus();
905
+ });
906
+
907
+ it('applies a sort with Enter and invalidates the page cursor', () => {
908
+ renderSort(panelProps);
909
+ fireEvent.click(screen.getByRole('button'));
910
+
911
+ fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' });
912
+ fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' });
913
+
914
+ expect(push).toHaveBeenCalledWith('/shoes?sorter=price_desc');
915
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
916
+ });
917
+
918
+ it('applies a sort with a pointer', () => {
919
+ renderSort(panelProps);
920
+ fireEvent.click(screen.getByRole('button'));
921
+ fireEvent.click(screen.getByText('Newest'));
922
+
923
+ expect(push).toHaveBeenCalledWith('/shoes?sorter=newest');
924
+ });
925
+
926
+ it('closes on Escape and hands focus back to the trigger', () => {
927
+ renderSort(panelProps);
928
+ const trigger = screen.getByRole('button');
929
+
930
+ fireEvent.click(trigger);
931
+ fireEvent.keyDown(document, { key: 'Escape' });
932
+
933
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
934
+ expect(trigger).toHaveFocus();
935
+ });
936
+
937
+ it('closes on an outside pointer', () => {
938
+ renderSort(panelProps);
939
+ fireEvent.click(screen.getByRole('button'));
940
+
941
+ fireEvent.mouseDown(document.body);
942
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
943
+ });
944
+
945
+ it('stays open when the pointer lands inside it', () => {
946
+ renderSort(panelProps);
947
+ fireEvent.click(screen.getByRole('button'));
948
+
949
+ fireEvent.mouseDown(screen.getByRole('listbox'));
950
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
951
+ });
952
+
953
+ it('navigates nowhere in the designer', () => {
954
+ renderSort(panelProps, { isDesigner: true });
955
+
956
+ fireEvent.click(screen.getByRole('button'));
957
+ fireEvent.click(screen.getByText('Newest'));
958
+
959
+ expect(push).not.toHaveBeenCalled();
960
+ });
961
+ });