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

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 (35) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/checkout-pieces.test.tsx +420 -0
  4. package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
  5. package/src/__tests__/code-owned-region.test.tsx +30 -6
  6. package/src/__tests__/composable-region.test.tsx +481 -0
  7. package/src/__tests__/part-styles.test.ts +24 -4
  8. package/src/__tests__/registry-parity.test.ts +3 -0
  9. package/src/__tests__/route-slug.test.ts +11 -0
  10. package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
  11. package/src/blocks/block-renderer-registry.tsx +6 -0
  12. package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
  13. package/src/blocks/checkout-outlet-base.tsx +241 -0
  14. package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
  15. package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
  16. package/src/chrome/checkout-piece.tsx +145 -0
  17. package/src/chrome/checkout-pieces-provider.tsx +28 -0
  18. package/src/chrome/checkout-pieces.ts +173 -0
  19. package/src/chrome/chrome-fallback.tsx +8 -26
  20. package/src/chrome/chrome-slot.tsx +25 -26
  21. package/src/chrome/code-owned-region.tsx +21 -9
  22. package/src/chrome/composable-region-client.tsx +228 -0
  23. package/src/chrome/composable-region.tsx +58 -0
  24. package/src/chrome/composition-source.ts +34 -0
  25. package/src/chrome/composition-state.ts +49 -0
  26. package/src/chrome/live-checkout-page-context.tsx +3 -2
  27. package/src/chrome/placeholder-slugs.ts +14 -4
  28. package/src/chrome/route-slug.ts +6 -3
  29. package/src/hooks/use-designer-features.tsx +41 -10
  30. package/src/index.ts +6 -0
  31. package/src/utils/__tests__/checkout-safety.test.ts +374 -0
  32. package/src/utils/checkout-safety.ts +177 -0
  33. package/src/utils/index.ts +47 -8
  34. package/src/utils/part-styles.ts +4 -0
  35. package/src/utils/visibility-rules.ts +31 -8
@@ -0,0 +1,623 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { act, fireEvent, render, screen } from '@testing-library/react';
4
+ import CheckoutFlowOutletBlock from '../checkout-flow-outlet-block';
5
+ import CheckoutStepsOutletBlock from '../checkout-steps-outlet-block';
6
+ import CheckoutSummaryOutletBlock from '../checkout-summary-outlet-block';
7
+ import {
8
+ getCheckoutFlowVariantOverride,
9
+ getCheckoutOutletClaim,
10
+ registerCheckoutOutlet,
11
+ resetCheckoutPiecesForTests,
12
+ updateCheckoutOutletFlowVariant
13
+ } from '../../chrome/checkout-pieces';
14
+ import { postToEditor } from '../../editor-bridge';
15
+
16
+ jest.mock('../../editor-bridge', () => ({
17
+ postToEditor: jest.fn()
18
+ }));
19
+
20
+ const postToEditorMock = postToEditor as jest.Mock;
21
+
22
+ /**
23
+ * The outlet blocks are positioning targets for the checkout pieces bridge.
24
+ * These tests lock the three properties the feature stands on:
25
+ *
26
+ * - SEALED (I1): an outlet never emits anything interactive — no inputs, no
27
+ * buttons, no forms, no iframes. It cannot render payment surfaces and it
28
+ * cannot synthesize a place-order trigger.
29
+ * - STABLE TARGET: the portal target div exists unconditionally and its DOM
30
+ * identity never changes across re-renders, so iframes portaled into it by
31
+ * the flow piece are never remounted mid-payment.
32
+ * - ZONE GATE: an outlet only registers inside the `checkout-page-body`
33
+ * placeholder. Anywhere else (trailing checkout zones, other pages) it is
34
+ * inert on live AND on the canvas, and its designer band says so — the
35
+ * canvas must never teleport into a spot the storefront would ignore.
36
+ */
37
+
38
+ const props = (over: Record<string, unknown> = {}) =>
39
+ ({
40
+ block: {
41
+ id: 'outlet-1',
42
+ type: 'checkout-flow-outlet',
43
+ label: 'Checkout Flow Outlet',
44
+ value: '',
45
+ properties: {},
46
+ styles: {},
47
+ order: 0,
48
+ hidden: false
49
+ },
50
+ placeholderId: 'checkout-page-body',
51
+ sectionId: 's',
52
+ isDesigner: false,
53
+ ...over
54
+ }) as any;
55
+
56
+ const flowProps = (
57
+ properties: Record<string, unknown>,
58
+ over: Record<string, unknown> = {}
59
+ ) => props({ block: { ...props().block, properties }, ...over });
60
+
61
+ const detachedNodes: HTMLElement[] = [];
62
+
63
+ const createDetachedOutletNode = () => {
64
+ const node = document.createElement('div');
65
+ document.body.appendChild(node);
66
+ detachedNodes.push(node);
67
+
68
+ return node;
69
+ };
70
+
71
+ beforeEach(() => {
72
+ resetCheckoutPiecesForTests();
73
+ postToEditorMock.mockClear();
74
+ });
75
+
76
+ afterEach(() => {
77
+ detachedNodes.forEach((node) => node.remove());
78
+ detachedNodes.length = 0;
79
+ });
80
+
81
+ describe('checkout outlet blocks — sealed live output (I1)', () => {
82
+ it('renders exactly one empty target div on live, nothing else', () => {
83
+ const { container } = render(<CheckoutFlowOutletBlock {...props()} />);
84
+
85
+ expect(container.innerHTML).toBe('<div data-checkout-outlet="flow"></div>');
86
+ });
87
+
88
+ it('never emits interactive elements, for any of the three outlets', () => {
89
+ const { container } = render(
90
+ <>
91
+ <CheckoutFlowOutletBlock {...props()} />
92
+ <CheckoutStepsOutletBlock {...props()} />
93
+ <CheckoutSummaryOutletBlock {...props()} />
94
+ </>
95
+ );
96
+
97
+ expect(
98
+ container.querySelector(
99
+ 'input, button, form, iframe, select, textarea, [role="button"]'
100
+ )
101
+ ).toBeNull();
102
+ expect(container.querySelectorAll('[data-checkout-outlet]')).toHaveLength(
103
+ 3
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('checkout outlet blocks — registration lifecycle', () => {
109
+ it('registers its target on mount and unregisters on unmount', () => {
110
+ const { container, unmount } = render(
111
+ <CheckoutSummaryOutletBlock {...props()} />
112
+ );
113
+
114
+ const claim = getCheckoutOutletClaim('summary');
115
+ expect(claim).not.toBeNull();
116
+ expect(claim!.node).toBe(
117
+ container.querySelector('[data-checkout-outlet="summary"]')
118
+ );
119
+
120
+ unmount();
121
+
122
+ expect(getCheckoutOutletClaim('summary')).toBeNull();
123
+ });
124
+
125
+ it('keeps the target node identity stable across re-renders', () => {
126
+ const { rerender } = render(
127
+ <CheckoutStepsOutletBlock {...props({ isSelected: false })} />
128
+ );
129
+
130
+ const before = getCheckoutOutletClaim('steps');
131
+ expect(before).not.toBeNull();
132
+
133
+ rerender(<CheckoutStepsOutletBlock {...props({ isSelected: true })} />);
134
+
135
+ const after = getCheckoutOutletClaim('steps');
136
+ expect(after).not.toBeNull();
137
+ expect(after!.node).toBe(before!.node);
138
+ expect(after!.id).toBe(before!.id);
139
+ });
140
+ });
141
+
142
+ describe('checkout outlet blocks — designer bands', () => {
143
+ it('shows the "renders here" band in the designer while the piece is absent', () => {
144
+ render(<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />);
145
+
146
+ expect(
147
+ screen.getByText('Checkout flow renders here on the storefront')
148
+ ).toBeInTheDocument();
149
+ });
150
+
151
+ it('shows no band on live', () => {
152
+ const { container } = render(<CheckoutFlowOutletBlock {...props()} />);
153
+
154
+ expect(
155
+ container.querySelector('[data-theme-block-placeholder]')
156
+ ).toBeNull();
157
+ });
158
+
159
+ it('marks the second outlet for the same piece as a duplicate (first-wins)', () => {
160
+ render(
161
+ <>
162
+ <CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
163
+ <CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
164
+ </>
165
+ );
166
+
167
+ expect(
168
+ screen.getAllByText(
169
+ 'Checkout flow — already placed in another outlet on this page'
170
+ )
171
+ ).toHaveLength(1);
172
+ expect(
173
+ screen.getAllByText('Checkout flow renders here on the storefront')
174
+ ).toHaveLength(1);
175
+ });
176
+ });
177
+
178
+ describe('checkout outlet blocks — zone gate', () => {
179
+ // The flow latches one-shot at page mount and only the body slot hydrates
180
+ // before that latch; a trailing-zone outlet would register too late and be
181
+ // silently dead on live while the canvas teleported into it. The gate makes
182
+ // canvas and storefront agree: outside checkout-page-body an outlet never
183
+ // registers, anywhere.
184
+ it.each([
185
+ ['checkout-bottom-body'],
186
+ ['checkout-top-body'],
187
+ ['theme-homepage-body']
188
+ ])('never registers from the %s placeholder', (placeholderId) => {
189
+ const { container } = render(
190
+ <CheckoutFlowOutletBlock {...props({ placeholderId })} />
191
+ );
192
+
193
+ expect(getCheckoutOutletClaim('flow')).toBeNull();
194
+ // Live output stays the sealed empty div, nothing else.
195
+ expect(container.innerHTML).toBe('<div data-checkout-outlet="flow"></div>');
196
+ });
197
+
198
+ it('applies the gate to all three outlets', () => {
199
+ render(
200
+ <>
201
+ <CheckoutFlowOutletBlock
202
+ {...props({ placeholderId: 'checkout-bottom-body' })}
203
+ />
204
+ <CheckoutStepsOutletBlock
205
+ {...props({ placeholderId: 'checkout-bottom-body' })}
206
+ />
207
+ <CheckoutSummaryOutletBlock
208
+ {...props({ placeholderId: 'checkout-bottom-body' })}
209
+ />
210
+ </>
211
+ );
212
+
213
+ expect(getCheckoutOutletClaim('flow')).toBeNull();
214
+ expect(getCheckoutOutletClaim('steps')).toBeNull();
215
+ expect(getCheckoutOutletClaim('summary')).toBeNull();
216
+ });
217
+
218
+ it('explains itself in the designer instead of promising a render', () => {
219
+ render(
220
+ <CheckoutFlowOutletBlock
221
+ {...props({ placeholderId: 'checkout-bottom-body', isDesigner: true })}
222
+ />
223
+ );
224
+
225
+ expect(
226
+ screen.getByText(
227
+ 'Checkout flow outlet only works inside the Checkout Body zone — the storefront ignores it here'
228
+ )
229
+ ).toBeInTheDocument();
230
+ expect(
231
+ screen.queryByText('Checkout flow renders here on the storefront')
232
+ ).toBeNull();
233
+ expect(
234
+ screen.queryByText(
235
+ 'Checkout flow — already placed in another outlet on this page'
236
+ )
237
+ ).toBeNull();
238
+ });
239
+ });
240
+
241
+ describe('checkout flow outlet — composed variant channel', () => {
242
+ it('registers a valid flowVariant with its claim', () => {
243
+ render(
244
+ <CheckoutFlowOutletBlock {...flowProps({ flowVariant: 'one-page' })} />
245
+ );
246
+
247
+ expect(getCheckoutOutletClaim('flow')?.flowVariant).toBe('one-page');
248
+ expect(getCheckoutFlowVariantOverride()).toBe('one-page');
249
+ });
250
+
251
+ it('is breakpoint-invariant: the desktop value is authoritative', () => {
252
+ render(
253
+ <CheckoutFlowOutletBlock
254
+ {...flowProps({
255
+ flowVariant: { desktop: 'step-by-step', mobile: 'one-page' }
256
+ })}
257
+ />
258
+ );
259
+
260
+ expect(getCheckoutFlowVariantOverride()).toBe('step-by-step');
261
+ });
262
+
263
+ it.each([
264
+ ['inherit'],
265
+ ['accordion'],
266
+ ['basket-and-checkout'],
267
+ [42],
268
+ [null],
269
+ [{ desktop: 'accordion' }],
270
+ [undefined]
271
+ ])('treats %p as inherit and registers no preference', (value) => {
272
+ render(<CheckoutFlowOutletBlock {...flowProps({ flowVariant: value })} />);
273
+
274
+ expect(getCheckoutOutletClaim('flow')).not.toBeNull();
275
+ expect(getCheckoutOutletClaim('flow')?.flowVariant).toBeUndefined();
276
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
277
+ });
278
+
279
+ it('applies a designer property edit in place, never re-registering', () => {
280
+ const { rerender } = render(
281
+ <CheckoutFlowOutletBlock {...flowProps({ flowVariant: 'one-page' })} />
282
+ );
283
+
284
+ const before = getCheckoutOutletClaim('flow');
285
+ expect(before?.flowVariant).toBe('one-page');
286
+
287
+ rerender(
288
+ <CheckoutFlowOutletBlock
289
+ {...flowProps({ flowVariant: 'step-by-step' })}
290
+ />
291
+ );
292
+
293
+ const after = getCheckoutOutletClaim('flow');
294
+ // Same registration — same queue slot, same target node identity.
295
+ expect(after?.id).toBe(before?.id);
296
+ expect(after?.node).toBe(before?.node);
297
+ expect(after?.flowVariant).toBe('step-by-step');
298
+ });
299
+ });
300
+
301
+ describe('checkout outlets — designer canvas click capture', () => {
302
+ const renderFlowInWrapper = (over: Record<string, unknown> = {}) => {
303
+ const rendered = render(
304
+ <div data-block-id="outlet-1">
305
+ <CheckoutFlowOutletBlock {...props(over)} />
306
+ </div>
307
+ );
308
+ const target = rendered.container.querySelector<HTMLElement>(
309
+ '[data-checkout-outlet="flow"]'
310
+ )!;
311
+
312
+ return { ...rendered, target };
313
+ };
314
+
315
+ // The teleported markup is code-owned and full of live handlers; the tests
316
+ // simulate it by appending plain DOM under the portal target.
317
+ const appendPartButton = (target: HTMLElement, part: string) => {
318
+ const button = document.createElement('button');
319
+ button.setAttribute('data-part', part);
320
+ const liveHandler = jest.fn();
321
+ button.addEventListener('click', liveHandler);
322
+ target.appendChild(button);
323
+
324
+ return { button, liveHandler };
325
+ };
326
+
327
+ it('selects the clicked part and never lets the live handler fire (designer)', () => {
328
+ const { target } = renderFlowInWrapper({ isDesigner: true });
329
+ const { button, liveHandler } = appendPartButton(target, 'payment-option');
330
+
331
+ const notPrevented = fireEvent.click(button);
332
+
333
+ expect(liveHandler).not.toHaveBeenCalled();
334
+ expect(notPrevented).toBe(false);
335
+ expect(postToEditorMock).toHaveBeenCalledTimes(1);
336
+ expect(postToEditorMock).toHaveBeenCalledWith({
337
+ type: 'SELECT_BLOCK',
338
+ data: {
339
+ placeholderId: 'checkout-page-body',
340
+ sectionId: 's',
341
+ blockId: 'outlet-1',
342
+ blockType: 'checkout-flow-outlet',
343
+ blockLabel: 'Checkout Flow Outlet',
344
+ partName: 'payment-option'
345
+ }
346
+ });
347
+ });
348
+
349
+ it('does not claim a part owned by a nested block', () => {
350
+ const { target } = renderFlowInWrapper({ isDesigner: true });
351
+ const nested = document.createElement('div');
352
+ nested.setAttribute('data-block-id', 'nested-block');
353
+ target.appendChild(nested);
354
+ const { button, liveHandler } = appendPartButton(nested, 'header');
355
+
356
+ fireEvent.click(button);
357
+
358
+ // Still neutralized (the click is inside checkout markup on the canvas),
359
+ // but the part belongs to the nested block — only the outlet is selected.
360
+ expect(liveHandler).not.toHaveBeenCalled();
361
+ expect(postToEditorMock).toHaveBeenCalledWith(
362
+ expect.objectContaining({
363
+ type: 'SELECT_BLOCK',
364
+ data: expect.objectContaining({
365
+ blockId: 'outlet-1',
366
+ partName: undefined
367
+ })
368
+ })
369
+ );
370
+ });
371
+
372
+ it('attaches nothing on live: clicks reach their own handlers untouched', () => {
373
+ const { target } = renderFlowInWrapper();
374
+ const { button, liveHandler } = appendPartButton(target, 'payment-option');
375
+
376
+ const notPrevented = fireEvent.click(button);
377
+
378
+ expect(liveHandler).toHaveBeenCalledTimes(1);
379
+ expect(notPrevented).toBe(true);
380
+ expect(postToEditorMock).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
384
+ describe('checkout outlets — capture against a REAL React portal', () => {
385
+ /**
386
+ * The plain-DOM harness above proves the listener runs; it cannot prove
387
+ * anything about React's synthetic dispatch, which is what every real
388
+ * checkout handler uses. `createPortal` makes React attach its own
389
+ * delegated capture AND bubble listeners to the very node the outlet's
390
+ * capture listener sits on, so the ordering has to be verified with a real
391
+ * portal — including the case where `isDesigner` arrives late (it rides a
392
+ * postMessage) and the outlet re-registers AFTER the portal mounted.
393
+ */
394
+ const Portaled = ({ children }: { children: React.ReactNode }) => {
395
+ const [node, setNode] = useState<HTMLElement | null>(null);
396
+
397
+ useEffect(() => {
398
+ setNode(
399
+ document.querySelector<HTMLElement>('[data-checkout-outlet="flow"]')
400
+ );
401
+ }, []);
402
+
403
+ return node ? createPortal(children, node) : null;
404
+ };
405
+
406
+ const renderWithPortal = (
407
+ isDesigner: boolean,
408
+ children: React.ReactNode
409
+ ) => {
410
+ const tree = (designer: boolean) => (
411
+ <div data-block-id="outlet-1">
412
+ <CheckoutFlowOutletBlock {...props({ isDesigner: designer })} />
413
+ <Portaled>{children}</Portaled>
414
+ </div>
415
+ );
416
+
417
+ const rendered = render(tree(isDesigner));
418
+
419
+ return { ...rendered, rerenderAsDesigner: () => rendered.rerender(tree(true)) };
420
+ };
421
+
422
+ it('never lets a React onClick fire on the canvas', () => {
423
+ const onClick = jest.fn();
424
+ const { container } = renderWithPortal(
425
+ true,
426
+ <button data-part="payment-option" onClick={onClick}>
427
+ Credit Card
428
+ </button>
429
+ );
430
+
431
+ fireEvent.click(container.querySelector('[data-part]')!);
432
+
433
+ expect(onClick).not.toHaveBeenCalled();
434
+ expect(postToEditorMock).toHaveBeenCalledWith(
435
+ expect.objectContaining({
436
+ data: expect.objectContaining({ partName: 'payment-option' })
437
+ })
438
+ );
439
+ });
440
+
441
+ it('still holds when isDesigner arrives AFTER the portal mounted', () => {
442
+ const onClick = jest.fn();
443
+ const { container, rerenderAsDesigner } = renderWithPortal(
444
+ false,
445
+ <button data-part="payment-option" onClick={onClick}>
446
+ Credit Card
447
+ </button>
448
+ );
449
+
450
+ rerenderAsDesigner();
451
+ fireEvent.click(container.querySelector('[data-part]')!);
452
+
453
+ expect(onClick).not.toHaveBeenCalled();
454
+ });
455
+
456
+ it('blocks the submit a keystroke produces without any click', () => {
457
+ // Enter inside a card-number field triggers IMPLICIT form submission: the
458
+ // form gets a `submit` event and no click ever happens, so a click-only
459
+ // guard would let a canvas keystroke place a real order.
460
+ const onSubmit = jest.fn((event: React.FormEvent) =>
461
+ event.preventDefault()
462
+ );
463
+ const { container } = renderWithPortal(
464
+ true,
465
+ <form onSubmit={onSubmit}>
466
+ <input data-part="card-number" />
467
+ <button type="submit" data-part="place-order-button">
468
+ Pay
469
+ </button>
470
+ </form>
471
+ );
472
+
473
+ fireEvent.submit(container.querySelector('form')!);
474
+
475
+ expect(onSubmit).not.toHaveBeenCalled();
476
+ });
477
+
478
+ it('cancels the Enter default action, leaving propagation for editor shortcuts', () => {
479
+ const onKeyDown = jest.fn();
480
+ const { container } = renderWithPortal(
481
+ true,
482
+ <input data-part="card-number" onKeyDown={onKeyDown} />
483
+ );
484
+
485
+ const notPrevented = fireEvent.keyDown(
486
+ container.querySelector('[data-part]')!,
487
+ { key: 'Enter' }
488
+ );
489
+
490
+ expect(notPrevented).toBe(false);
491
+ // Propagation is deliberately NOT stopped — the editor still needs keys.
492
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
493
+ });
494
+
495
+ it('leaves the live page completely untouched', () => {
496
+ const onClick = jest.fn();
497
+ const onSubmit = jest.fn((event: React.FormEvent) =>
498
+ event.preventDefault()
499
+ );
500
+ const { container } = renderWithPortal(
501
+ false,
502
+ <form onSubmit={onSubmit}>
503
+ <button type="button" data-part="payment-option" onClick={onClick}>
504
+ Credit Card
505
+ </button>
506
+ </form>
507
+ );
508
+
509
+ fireEvent.click(container.querySelector('[data-part]')!);
510
+ fireEvent.submit(container.querySelector('form')!);
511
+
512
+ expect(onClick).toHaveBeenCalledTimes(1);
513
+ expect(onSubmit).toHaveBeenCalledTimes(1);
514
+ expect(postToEditorMock).not.toHaveBeenCalled();
515
+ });
516
+ });
517
+
518
+ describe('checkout outlets — the hidden flag stops the claim', () => {
519
+ // Live drops a hidden block before it mounts; the designer deliberately
520
+ // keeps it mounted under a display:none wrapper. Without this gate the
521
+ // hidden outlet still won the claim on the canvas and the piece portaled
522
+ // the real flow into an invisible subtree — checkout vanished from the
523
+ // canvas while the storefront rendered it in its default position.
524
+ const hidden = (over: Record<string, unknown> = {}) =>
525
+ props({ block: { ...props().block, hidden: true }, ...over });
526
+
527
+ it('never registers, on the canvas or on live', () => {
528
+ render(<CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />);
529
+ expect(getCheckoutOutletClaim('flow')).toBeNull();
530
+
531
+ render(<CheckoutSummaryOutletBlock {...hidden()} />);
532
+ expect(getCheckoutOutletClaim('summary')).toBeNull();
533
+ });
534
+
535
+ it('hands the claim to the next outlet instead of swallowing it', () => {
536
+ render(
537
+ <>
538
+ <CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />
539
+ <CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
540
+ </>
541
+ );
542
+
543
+ expect(getCheckoutOutletClaim('flow')).not.toBeNull();
544
+ expect(
545
+ screen.getByText('Checkout flow renders here on the storefront')
546
+ ).toBeInTheDocument();
547
+ expect(
548
+ screen.queryByText(
549
+ 'Checkout flow — already placed in another outlet on this page'
550
+ )
551
+ ).toBeNull();
552
+ });
553
+
554
+ it('registers again when the flag is cleared', () => {
555
+ const { rerender } = render(
556
+ <CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />
557
+ );
558
+ expect(getCheckoutOutletClaim('flow')).toBeNull();
559
+
560
+ rerender(<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />);
561
+ expect(getCheckoutOutletClaim('flow')).not.toBeNull();
562
+ });
563
+ });
564
+
565
+ describe('checkout steps outlet — one-page honesty note', () => {
566
+ const NOTE =
567
+ 'Step indicator only renders with the Step by step checkout — the One page checkout draws its own section numbers';
568
+
569
+ it('explains itself when the composition prefers the one-page checkout', () => {
570
+ registerCheckoutOutlet('flow', {
571
+ id: 'flow-outlet',
572
+ node: createDetachedOutletNode(),
573
+ flowVariant: 'one-page'
574
+ });
575
+
576
+ render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
577
+
578
+ expect(screen.getByText(NOTE)).toBeInTheDocument();
579
+ });
580
+
581
+ it('stays quiet for step-by-step and inherit compositions', () => {
582
+ registerCheckoutOutlet('flow', {
583
+ id: 'flow-outlet',
584
+ node: createDetachedOutletNode(),
585
+ flowVariant: 'step-by-step'
586
+ });
587
+
588
+ render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
589
+
590
+ expect(screen.queryByText(NOTE)).toBeNull();
591
+ });
592
+
593
+ it('reacts to a designer-time variant edit', () => {
594
+ registerCheckoutOutlet('flow', {
595
+ id: 'flow-outlet',
596
+ node: createDetachedOutletNode(),
597
+ flowVariant: 'step-by-step'
598
+ });
599
+
600
+ render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
601
+ expect(screen.queryByText(NOTE)).toBeNull();
602
+
603
+ act(() => {
604
+ updateCheckoutOutletFlowVariant('flow', 'flow-outlet', 'one-page');
605
+ });
606
+
607
+ expect(screen.getByText(NOTE)).toBeInTheDocument();
608
+ });
609
+
610
+ it('never renders the note on live', () => {
611
+ registerCheckoutOutlet('flow', {
612
+ id: 'flow-outlet',
613
+ node: createDetachedOutletNode(),
614
+ flowVariant: 'one-page'
615
+ });
616
+
617
+ const { container } = render(<CheckoutStepsOutletBlock {...props()} />);
618
+
619
+ expect(container.innerHTML).toBe(
620
+ '<div data-checkout-outlet="steps"></div>'
621
+ );
622
+ });
623
+ });
@@ -58,6 +58,9 @@ import SocialLinksBlock from './social-links-block';
58
58
  import PaymentBadgesBlock from './payment-badges-block';
59
59
  import CheckoutSummaryBlock from './checkout-summary-block';
60
60
  import CheckoutStepsBlock from './checkout-steps-block';
61
+ import CheckoutFlowOutletBlock from './checkout-flow-outlet-block';
62
+ import CheckoutStepsOutletBlock from './checkout-steps-outlet-block';
63
+ import CheckoutSummaryOutletBlock from './checkout-summary-outlet-block';
61
64
  import OrderSuccessBlock from './order-success-block';
62
65
  import OrderTrackingBlock from './order-tracking-block';
63
66
  import ProductBreadcrumbBlock from './product-breadcrumb-block';
@@ -176,6 +179,9 @@ blockRendererRegistry.register('checkout-summary', CheckoutSummaryBlock);
176
179
  blockRendererRegistry.register('checkout-steps', CheckoutStepsBlock);
177
180
  blockRendererRegistry.register('order-success', OrderSuccessBlock);
178
181
  blockRendererRegistry.register('order-tracking', OrderTrackingBlock);
182
+ blockRendererRegistry.register('checkout-flow-outlet', CheckoutFlowOutletBlock);
183
+ blockRendererRegistry.register('checkout-steps-outlet', CheckoutStepsOutletBlock);
184
+ blockRendererRegistry.register('checkout-summary-outlet', CheckoutSummaryOutletBlock);
179
185
 
180
186
  // Catalog extras
181
187
  blockRendererRegistry.register('product-breadcrumb', ProductBreadcrumbBlock);